Repository: kitbagjs/router Branch: main Commit: 043b3178dfce Files: 417 Total size: 785.7 KB Directory structure: gitextract_w2q946kp/ ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ ├── auto-docs.yml │ ├── notify-stars.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .vscode/ │ └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── docs/ │ ├── .vitepress/ │ │ ├── config.ts │ │ └── theme/ │ │ ├── index.js │ │ └── styles/ │ │ └── vars.css │ ├── advanced-concepts/ │ │ ├── hooks.md │ │ ├── plugins.md │ │ ├── prefetching.md │ │ ├── redirects.md │ │ ├── rejections.md │ │ ├── route-matching.md │ │ ├── route-meta.md │ │ ├── route-narrowing.md │ │ └── route-state.md │ ├── api/ │ │ ├── components/ │ │ │ ├── RouterLink.md │ │ │ └── RouterView.md │ │ ├── compositions/ │ │ │ ├── useLink.md │ │ │ ├── useQueryValue.md │ │ │ ├── useRejection.md │ │ │ ├── useRoute.md │ │ │ └── useRouter.md │ │ ├── errors/ │ │ │ ├── DuplicateParamsError.md │ │ │ ├── MetaPropertyConflict.md │ │ │ ├── RouterNotInstalledError.md │ │ │ └── UseRouteInvalidError.md │ │ ├── functions/ │ │ │ ├── arrayOf.md │ │ │ ├── asUrlString.md │ │ │ ├── combineRoutes.md │ │ │ ├── createExternalRoute.md │ │ │ ├── createParam.md │ │ │ ├── createRejection.md │ │ │ ├── createRoute.md │ │ │ ├── createRouter.md │ │ │ ├── createRouterAssets.md │ │ │ ├── createRouterPlugin.md │ │ │ ├── createUrl.md │ │ │ ├── isUrlWithSchema.md │ │ │ ├── isWithComponent.md │ │ │ ├── isWithComponentProps.md │ │ │ ├── isWithComponentPropsRecord.md │ │ │ ├── isWithComponents.md │ │ │ ├── isWithParent.md │ │ │ ├── literal.md │ │ │ ├── tupleOf.md │ │ │ ├── unionOf.md │ │ │ ├── withDefault.md │ │ │ └── withParams.md │ │ ├── hooks/ │ │ │ ├── onAfterRouteLeave.md │ │ │ ├── onAfterRouteUpdate.md │ │ │ ├── onBeforeRouteLeave.md │ │ │ └── onBeforeRouteUpdate.md │ │ ├── index.md │ │ ├── interfaces/ │ │ │ └── Register.md │ │ ├── type-guards/ │ │ │ ├── isRoute.md │ │ │ └── isUrlString.md │ │ ├── typedoc-sidebar.json │ │ ├── types/ │ │ │ ├── AddAfterEnterHook.md │ │ │ ├── AddAfterLeaveHook.md │ │ │ ├── AddAfterUpdateHook.md │ │ │ ├── AddBeforeEnterHook.md │ │ │ ├── AddBeforeLeaveHook.md │ │ │ ├── AddBeforeUpdateHook.md │ │ │ ├── AddComponentHook.md │ │ │ ├── AddErrorHook.md │ │ │ ├── AddGlobalHooks.md │ │ │ ├── AddPluginErrorHook.md │ │ │ ├── AfterEnterHook.md │ │ │ ├── AfterEnterHookContext.md │ │ │ ├── AfterHookLifecycle.md │ │ │ ├── AfterHookResponse.md │ │ │ ├── AfterHookRunner.md │ │ │ ├── AfterLeaveHook.md │ │ │ ├── AfterLeaveHookContext.md │ │ │ ├── AfterUpdateHook.md │ │ │ ├── AfterUpdateHookContext.md │ │ │ ├── BeforeEnterHook.md │ │ │ ├── BeforeEnterHookContext.md │ │ │ ├── BeforeHookLifecycle.md │ │ │ ├── BeforeHookResponse.md │ │ │ ├── BeforeHookRunner.md │ │ │ ├── BeforeLeaveHook.md │ │ │ ├── BeforeLeaveHookContext.md │ │ │ ├── BeforeUpdateHook.md │ │ │ ├── BeforeUpdateHookContext.md │ │ │ ├── ComponentHook.md │ │ │ ├── ComponentHookRegistration.md │ │ │ ├── CreateRouteOptions.md │ │ │ ├── CreateRouteProps.md │ │ │ ├── CreateRouterPluginOptions.md │ │ │ ├── CreateUrlOptions.md │ │ │ ├── CreatedRouteOptions.md │ │ │ ├── EmptyRouterPlugin.md │ │ │ ├── ErrorHook.md │ │ │ ├── ErrorHookContext.md │ │ │ ├── ErrorHookRunner.md │ │ │ ├── ErrorHookRunnerContext.md │ │ │ ├── ExternalRouteHooks.md │ │ │ ├── GenericRoute.md │ │ │ ├── HookLifecycle.md │ │ │ ├── HookRemove.md │ │ │ ├── HookTiming.md │ │ │ ├── InternalRouteHooks.md │ │ │ ├── LiteralParam.md │ │ │ ├── Param.md │ │ │ ├── ParamExtras.md │ │ │ ├── ParamGetSet.md │ │ │ ├── ParamGetter.md │ │ │ ├── ParamSetter.md │ │ │ ├── ParseUrlOptions.md │ │ │ ├── PluginAfterRouteHook.md │ │ │ ├── PluginBeforeRouteHook.md │ │ │ ├── PluginErrorHook.md │ │ │ ├── PluginErrorHookContext.md │ │ │ ├── PluginRouteHooks.md │ │ │ ├── PrefetchConfig.md │ │ │ ├── PrefetchConfigOptions.md │ │ │ ├── PrefetchConfigs.md │ │ │ ├── PrefetchStrategy.md │ │ │ ├── PropsCallbackContext.md │ │ │ ├── PropsCallbackParent.md │ │ │ ├── PropsGetter.md │ │ │ ├── QuerySource.md │ │ │ ├── RegisteredRouter.md │ │ │ ├── ResolvedRoute.md │ │ │ ├── ResolvedRouteUnion.md │ │ │ ├── Route.md │ │ │ ├── RouteMeta.md │ │ │ ├── Router.md │ │ │ ├── RouterAssets.md │ │ │ ├── RouterLinkProps.md │ │ │ ├── RouterOptions.md │ │ │ ├── RouterPlugin.md │ │ │ ├── RouterPush.md │ │ │ ├── RouterPushOptions.md │ │ │ ├── RouterReject.md │ │ │ ├── RouterRejections.md │ │ │ ├── RouterReplace.md │ │ │ ├── RouterReplaceOptions.md │ │ │ ├── RouterResolve.md │ │ │ ├── RouterResolveOptions.md │ │ │ ├── RouterResolvedRouteUnion.md │ │ │ ├── RouterRoute.md │ │ │ ├── RouterRouteName.md │ │ │ ├── RouterRouteUnion.md │ │ │ ├── RouterRoutes.md │ │ │ ├── RouterViewPropsGetter.md │ │ │ ├── Routes.md │ │ │ ├── ToCallback.md │ │ │ ├── ToRoute.md │ │ │ ├── ToUrl.md │ │ │ ├── Url.md │ │ │ ├── UrlParamsReading.md │ │ │ ├── UrlParamsWriting.md │ │ │ ├── UrlString.md │ │ │ ├── UseLink.md │ │ │ ├── UseLinkOptions.md │ │ │ ├── WithHost.md │ │ │ ├── WithParent.md │ │ │ ├── WithoutHost.md │ │ │ └── WithoutParent.md │ │ └── variables/ │ │ └── IS_URL_SYMBOL.md │ ├── components/ │ │ ├── router-link.md │ │ └── router-view.md │ ├── composables/ │ │ ├── useLink.md │ │ ├── useQueryValue.md │ │ ├── useRoute.md │ │ └── useRouter.md │ ├── core-concepts/ │ │ ├── component-props.md │ │ ├── external-routes.md │ │ ├── navigation.md │ │ ├── params.md │ │ ├── router-route.md │ │ ├── router.md │ │ └── routes.md │ ├── index.md │ ├── introduction.md │ ├── migrating-vue-router.md │ └── quick-start.md ├── eslint.config.js ├── package.json ├── scripts/ │ └── api.js ├── src/ │ ├── components/ │ │ ├── echo.ts │ │ ├── helloWorld.ts │ │ ├── rejection.ts │ │ ├── routerLink.browser.spec.ts │ │ ├── routerLink.ts │ │ ├── routerView.browser.spec.ts │ │ ├── routerView.spec.ts │ │ └── routerView.ts │ ├── compositions/ │ │ ├── useComponentsStore.ts │ │ ├── useEventListener.ts │ │ ├── useLink.ts │ │ ├── usePrefetching.ts │ │ ├── usePropStore.ts │ │ ├── useQueryValue.browser.spec.ts │ │ ├── useQueryValue.spec-d.ts │ │ ├── useQueryValue.ts │ │ ├── useRejection.ts │ │ ├── useRoute.browser.spec.ts │ │ ├── useRoute.spec-d.ts │ │ ├── useRoute.ts │ │ ├── useRouter.ts │ │ ├── useRouterDepth.ts │ │ ├── useRouterHooks.ts │ │ └── useVisibilityObserver.ts │ ├── devtools/ │ │ ├── createRouterDevtools.ts │ │ ├── filters.ts │ │ ├── getDevtoolsLabel.ts │ │ └── types.ts │ ├── errors/ │ │ ├── contextAbortError.ts │ │ ├── contextError.ts │ │ ├── contextPushError.ts │ │ ├── contextRejectionError.ts │ │ ├── duplicateNamesError.ts │ │ ├── duplicateParamsError.ts │ │ ├── initialRouteMissingError.ts │ │ ├── invalidRouteParamValueError.ts │ │ ├── invalidRouteRedirectError.ts │ │ ├── metaPropertyConflict.ts │ │ ├── multipleRouteRedirectsError.ts │ │ ├── routeNotFoundError.ts │ │ ├── routerNotInstalledError.ts │ │ └── useRouteInvalidError.ts │ ├── guards/ │ │ ├── routes.spec-d.ts │ │ └── routes.ts │ ├── keys.ts │ ├── main.ts │ ├── models/ │ │ └── hooks.ts │ ├── services/ │ │ ├── arrayOf.spec.ts │ │ ├── arrayOf.ts │ │ ├── combineHash.spec.ts │ │ ├── combineHash.ts │ │ ├── combineMeta.spec.ts │ │ ├── combineMeta.ts │ │ ├── combinePath.spec-d.ts │ │ ├── combinePath.spec.ts │ │ ├── combinePath.ts │ │ ├── combineQuery.spec.ts │ │ ├── combineQuery.ts │ │ ├── combineState.spec.ts │ │ ├── combineState.ts │ │ ├── combineUrl.spec.ts │ │ ├── combineUrl.ts │ │ ├── component.browser.spec.ts │ │ ├── component.ts │ │ ├── createComponentHooks.ts │ │ ├── createComponentsStore.ts │ │ ├── createCurrentRejection.ts │ │ ├── createCurrentRoute.ts │ │ ├── createExternalRoute.spec.ts │ │ ├── createExternalRoute.ts │ │ ├── createIsExternal.spec.ts │ │ ├── createIsExternal.ts │ │ ├── createParam.ts │ │ ├── createPropStore.ts │ │ ├── createRejection.ts │ │ ├── createRejectionHooks.ts │ │ ├── createResolvedRoute.spec.ts │ │ ├── createResolvedRoute.ts │ │ ├── createResolvedRouteQuery.ts │ │ ├── createRoute.spec-d.ts │ │ ├── createRoute.spec.ts │ │ ├── createRoute.ts │ │ ├── createRouteHooks.ts │ │ ├── createRouteId.ts │ │ ├── createRouteRedirects.spec.ts │ │ ├── createRouteRedirects.ts │ │ ├── createRouter.browser.spec.ts │ │ ├── createRouter.spec-d.ts │ │ ├── createRouter.spec.ts │ │ ├── createRouter.ts │ │ ├── createRouterAssets.ts │ │ ├── createRouterCallbackContext.ts │ │ ├── createRouterHistory.browser.spec.ts │ │ ├── createRouterHistory.ts │ │ ├── createRouterHooks.ts │ │ ├── createRouterKeyStore.ts │ │ ├── createRouterPlugin.browser.spec.ts │ │ ├── createRouterPlugin.spec-d.ts │ │ ├── createRouterPlugin.ts │ │ ├── createRouterRoute.spec.ts │ │ ├── createRouterRoute.ts │ │ ├── createUniqueIdSequence.ts │ │ ├── createUrl.spec.ts │ │ ├── createUrl.ts │ │ ├── createVisibilityObserver.ts │ │ ├── createVueAppStore.ts │ │ ├── getGlobalHooksForRouter.ts │ │ ├── getGlobalRouteHooks.ts │ │ ├── getInitialUrl.browser.spec.ts │ │ ├── getInitialUrl.spec.ts │ │ ├── getInitialUrl.ts │ │ ├── getMatchesForUrl.spec.ts │ │ ├── getMatchesForUrl.ts │ │ ├── getParamsForString.ts │ │ ├── getRejectionHooks.ts │ │ ├── getRouteHooks.spec.ts │ │ ├── getRouteHooks.ts │ │ ├── getRoutesForRouter.spec.ts │ │ ├── getRoutesForRouter.ts │ │ ├── history.browser.spec.ts │ │ ├── history.ts │ │ ├── hooks.browser.spec.ts │ │ ├── hooks.spec.ts │ │ ├── hooks.ts │ │ ├── insertBaseRoute.spec.ts │ │ ├── insertBaseRoute.ts │ │ ├── literal.ts │ │ ├── params.spec.ts │ │ ├── params.ts │ │ ├── paramsFinder.spec.ts │ │ ├── paramsFinder.ts │ │ ├── queryParamFilter.spec.ts │ │ ├── queryParamFilter.ts │ │ ├── routeRegex.spec.ts │ │ ├── routeRegex.ts │ │ ├── state.spec.ts │ │ ├── state.ts │ │ ├── tupleOf.spec.ts │ │ ├── tupleOf.ts │ │ ├── unionOf.spec.ts │ │ ├── unionOf.ts │ │ ├── urlParser.spec.ts │ │ ├── urlParser.ts │ │ ├── valibot.spec-d.ts │ │ ├── valibot.spec.ts │ │ ├── valibot.ts │ │ ├── withDefault.ts │ │ ├── withParams.spec-d.ts │ │ ├── withParams.spec.ts │ │ ├── withParams.ts │ │ ├── zod.spec-d.ts │ │ ├── zod.spec.ts │ │ └── zod.ts │ ├── tests/ │ │ ├── hooks.spec.ts │ │ ├── routeProps.browser.spec.ts │ │ └── routeProps.spec.ts │ ├── types/ │ │ ├── callbackContext.ts │ │ ├── createRouteOptions.ts │ │ ├── hooks.ts │ │ ├── meta.ts │ │ ├── name.ts │ │ ├── paramTypes.ts │ │ ├── params.ts │ │ ├── prefetch.ts │ │ ├── props.ts │ │ ├── querySource.ts │ │ ├── redirects.spec-d.ts │ │ ├── redirects.ts │ │ ├── register.spec.ts │ │ ├── register.ts │ │ ├── rejection.ts │ │ ├── resolved.spec-d.ts │ │ ├── resolved.ts │ │ ├── route.spec-d.ts │ │ ├── route.ts │ │ ├── routeContext.ts │ │ ├── routeTitle.browser.spec.ts │ │ ├── routeTitle.ts │ │ ├── routeUpdate.ts │ │ ├── routeWithParams.spec-d.ts │ │ ├── routeWithParams.ts │ │ ├── router.ts │ │ ├── routerAbort.ts │ │ ├── routerLink.ts │ │ ├── routerPlugin.ts │ │ ├── routerPush.ts │ │ ├── routerReject.ts │ │ ├── routerReplace.ts │ │ ├── routerResolve.ts │ │ ├── routerRoute.ts │ │ ├── routesMap.spec-ts.ts │ │ ├── routesMap.ts │ │ ├── state.ts │ │ ├── url.ts │ │ ├── urlString.ts │ │ ├── useLink.ts │ │ └── utilities.ts │ └── utilities/ │ ├── array.ts │ ├── checkDuplicateNames.spec.ts │ ├── checkDuplicateNames.ts │ ├── checkDuplicateParams.spec.ts │ ├── checkDuplicateParams.ts │ ├── components.spec.ts │ ├── components.ts │ ├── guards.spec.ts │ ├── guards.ts │ ├── index.ts │ ├── isBrowser.browser.spec.ts │ ├── isBrowser.spec.ts │ ├── isBrowser.ts │ ├── isNamedRoute.ts │ ├── makeOptional.ts │ ├── prefetch.spec.ts │ ├── prefetch.ts │ ├── promises.ts │ ├── props.ts │ ├── setDocumentTitle.ts │ ├── testHelpers.ts │ ├── trailingSlashes.spec.ts │ ├── trailingSlashes.ts │ ├── urlSearchParams.spec.ts │ └── urlSearchParams.ts ├── tsconfig.json ├── typedoc.mjs ├── typedoc.tsconfig.json └── vite.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: pleek91, stackoverfloweth ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" ignore: - dependency-name: "@standard-schema/spec" update-types: ["version-update:semver-minor", "version-update:semver-patch"] - dependency-name: "zod" update-types: ["version-update:semver-minor", "version-update:semver-patch"] groups: build-tools: patterns: - "vite" - "vite-plugin-dts" - "@vitejs/plugin-vue" - "typescript" - "vue-tsc" testing: patterns: - "vitest" - "@vue/test-utils" - "happy-dom" docs: patterns: - "vitepress" - "typedoc" - "typedoc-plugin-markdown" - "typedoc-vitepress-theme" linting: patterns: - "eslint" - "@kitbag/eslint-config" - "globals" ================================================ FILE: .github/workflows/auto-docs.yml ================================================ name: Auto Generate Docs on: push: tags: - 'v*' # Trigger on version tags (v0.20.11, v1.0.0, etc.) workflow_dispatch: # Allow manual trigger permissions: contents: write pull-requests: write jobs: generate-docs: name: Generate Documentation runs-on: ubuntu-latest steps: - name: Setup id: setup uses: kitbagjs/actions-setup-project@main - name: Generate docs run: npm run docs:generate - name: Create Pull Request uses: peter-evans/create-pull-request@v7 with: commit-message: 'docs: auto-generate documentation' title: 'docs: Auto-generate documentation' body: | This PR was automatically generated after a new release tag was created. The documentation has been regenerated using `npm run docs:generate`. Please review the changes and merge if everything looks correct. branch: auto-docs/update base: main delete-branch: true labels: | documentation automated ================================================ FILE: .github/workflows/notify-stars.yml ================================================ name: Stars Notification to Discord on: watch: types: [started] jobs: notify: name: Notify Discord runs-on: ubuntu-latest steps: - uses: kitbagjs/actions-notify-stars@main with: DISCORD_WEBHOOK_URL: ${{secrets.DISCORD_WEBHOOK_URL}} GITHUB_REPOSITORY: ${{github.event.repository.name}} GITHUB_REPOSITORY_STARS: ${{github.event.repository.stargazers_count}} GITHUB_SENDER_LOGIN: ${{github.event.sender.login}} GITHUB_SENDER_AVATAR_URL: ${{github.event.sender.avatar_url}} GITHUB_SENDER_HTML_URL: ${{github.event.sender.html_url}} ================================================ FILE: .github/workflows/release.yml ================================================ on: push: tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 name: Create Release jobs: release: name: Create Release runs-on: ubuntu-latest permissions: contents: write steps: - uses: kitbagjs/actions-create-release@main with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ name: Tests on: [pull_request] jobs: unit-tests: name: Unit Tests runs-on: ubuntu-latest steps: - name: Setup id: setup uses: kitbagjs/actions-setup-project@main - name: Run tests run: npm run test types: name: Types runs-on: ubuntu-latest steps: - name: Setup id: setup uses: kitbagjs/actions-setup-project@main - name: Run tsc run: npm run types ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist *.local tsconfig.vitest-temp.json docs/.vitepress/cache docs/.vitepress/dist ================================================ FILE: .nvmrc ================================================ v22.14.0 ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": false, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "eslint.rules.customizations": [ { "rule": "@stylistic/*", "fixable": true, "severity": "warn" } ], "typescript.tsdk": "node_modules/typescript/lib", "cSpell.words": [ "composables", "tseslint", "Valibot", "vitepress" ], } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of Conduct for Kitbag As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. This Code of Conduct is adapted from the Contributor Covenant, version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023-present, Craig Harshbarger Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # @kitbag/router Type safe router for Vue.js [![NPM Version][npm-badge]][npm-url] [![Netlify Status][netlify-badge]][netlify-url] [![Discord chat][discord-badge]][discord-url] [![Open in StackBlitz][stackblitz-badge]][stackblitz-url] [npm-badge]: https://img.shields.io/npm/v/@kitbag/router.svg [npm-url]: https://www.npmjs.org/package/@kitbag/router [netlify-badge]: https://api.netlify.com/api/v1/badges/c12f79b8-49f9-4529-bc23-f8ffca8919a3/deploy-status [netlify-url]: https://app.netlify.com/sites/kitbag-router/deploys [discord-badge]: https://img.shields.io/discord/1079625926024900739?logo=discord&label=Discord [discord-url]: https://discord.gg/zw7dpcc5HV [stackblitz-badge]: https://developer.stackblitz.com/img/open_in_stackblitz_small.svg [stackblitz-url]: https://stackblitz.com/~/github.com/kitbagjs/router-preview ## Getting Started Get Started with our [documentation](https://kitbag-router.netlify.app/). ## Installation Install Kitbag Router with your favorite package manager ```bash # bun bun add @kitbag/router # yarn yarn add @kitbag/router # npm npm install @kitbag/router ``` ## Define Routes Routes are created individually using the [`createRoute`](https://kitbag-router.netlify.app/api/functions/createRoute) utility. Learn more about [defining routes](https://kitbag-router.netlify.app/core-concepts/routes). ```ts import { createRoute } from '@kitbag/router' const Home = { template: '
Home
' } const About = { template: '
About
' } const routes = [ createRoute({ name: 'home', path: '/', component: Home }), createRoute({ name: 'path', path: '/about', component: About }), ] as const ``` > [!NOTE] Type Safety > Using `as const` when defining routes is important as it ensures the types are correctly inferred. ## Create Router A router is created using the [`createRouter`](https://kitbag-router.netlify.app/api/functions/createRouter) utility and passing in the routes. ```ts import { createRouter } from '@kitbag/router' const router = createRouter(routes) ``` ## Vue Plugin Create a router instance and pass it to the app as a plugin ```ts {6} import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) app.use(router) app.mount('#app') ``` ## Type Safety Kitbag Router utilizes [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) to provide the internal types to match the actual router you're using. ```ts declare module '@kitbag/router' { interface Register { router: typeof router } } ``` This means then when you import a component, composition, or hook from `@kitbag/router` it will be correctly typed. Alternatively, you can create your own typed router assets by using the [`createRouterAssets`](https://kitbag-router.netlify.app/api/functions/createRouterAssets) utility. This approach is especially useful for projects that use multiple routers. ## RouterView Give your route components a place to be mounted ```html
``` This component can be mounted anywhere you want route components to be mounted. Nested routes can also have a nested `RouterView` which would be responsible for rendering any children that route may have. Read more about [nested routes](https://kitbag-router.netlify.app/core-concepts/routes#parent). ## RouterLink Use RouterLink for navigating between routes. ```html ``` ### Type Safety in RouterLink The `to` prop accepts a callback function or a [`Url`](https://kitbag-router.netlify.app/api/types/Url) string. When using a callback function, the router will provide a `resolve` function that is a type safe way to create link for your pre-defined routes. ================================================ FILE: docs/.vitepress/config.ts ================================================ import { defineConfig } from 'vitepress' import typedocSidebar from '../api/typedoc-sidebar.json'; // https://vitepress.dev/reference/site-config export default defineConfig({ title: "Kitbag Router | Type safe router for Vue.js", description: "Type safe router for Vue.js", head: [ ['link', { rel: 'icon', href: '/favicon.ico' }], // Vue School Top banner [ 'script', { src: 'https://media.bitterbrains.com/main.js?from=KITBAG&type=top', // @ts-expect-error: vitepress bug async: true, type: 'text/javascript', }, ], ], themeConfig: { logo: '/kitbag-logo-circle.svg', siteTitle: 'Kitbag Router', editLink: { pattern: 'https://github.com/kitbagjs/router/edit/main/docs/:path', text: 'Suggest changes to this page', }, nav: [ { text: 'Guide', link: '/introduction' }, { text: 'API', link: '/api/index' } ], search: { provider: 'local' }, sidebar: { '/api/': typedocSidebar, '/': [ { text: 'Getting Started', items: [ { text: 'Introduction', link: '/introduction', }, { text: 'Quick Start', link: '/quick-start', }, ], }, { text: 'Core Concepts', items: [ { text: 'Routes', link: '/core-concepts/routes' }, { text: 'External Routes', link: '/core-concepts/external-routes' }, { text: 'Params', link: '/core-concepts/params' }, { text: 'Props', link: '/core-concepts/component-props' }, { text: 'Router', link: '/core-concepts/router' }, { text: 'Router Route', link: '/core-concepts/router-route' }, { text: 'Navigation', link: '/core-concepts/navigation' }, ], }, { text: 'Components', items: [ { text: 'RouterView', link: '/components/router-view' }, { text: 'RouterLink', link: '/components/router-link' }, ], }, { text: 'Composables', items: [ { text: 'useLink', link: '/composables/useLink' }, { text: 'useQueryValue', link: '/composables/useQueryValue' }, { text: 'useRoute', link: '/composables/useRoute' }, { text: 'useRouter', link: '/composables/useRouter' }, ], }, { text: 'Advanced Concepts', items: [ { text: 'Route Matching', link: '/advanced-concepts/route-matching' }, { text: 'Route Narrowing', link: '/advanced-concepts/route-narrowing' }, { text: 'Rejections', link: '/advanced-concepts/rejections' }, { text: 'Redirects', link: '/advanced-concepts/redirects' }, { text: 'Hooks', link: '/advanced-concepts/hooks' }, { text: 'Plugins', link: '/advanced-concepts/plugins' }, { text: 'Route Meta', link: '/advanced-concepts/route-meta' }, { text: 'Route State', link: '/advanced-concepts/route-state' }, { text: 'Prefetching', link: '/advanced-concepts/prefetching' }, ], }, { text: "Migrating from vue-router", link: "/migrating-vue-router" } ] }, socialLinks: [ { icon: { svg: ` ` }, link: 'https://kitbag.dev', ariaLabel: 'Kitbag Home' }, { icon: 'github', link: 'https://github.com/kitbagjs/router' }, { icon: 'discord', link: 'https://discord.gg/zw7dpcc5HV' }, { icon: 'npm', link: 'https://www.npmjs.com/package/@kitbag/router' }, ] }, markdown: { image: { lazyLoading: true } } }) ================================================ FILE: docs/.vitepress/theme/index.js ================================================ import DefaultTheme from 'vitepress/theme' import './styles/vars.css' export default DefaultTheme ================================================ FILE: docs/.vitepress/theme/styles/vars.css ================================================ :root { --kitbag-orange-300: rgba(254, 168, 68, 1); --kitbag-orange-500: rgba(212, 80, 66, 1); --kitbag-orange-700: rgba(239, 31, 68, 1); --kitbag-orange-700-75: rgba(239, 31, 68, 0.75); --vp-button-brand-bg: var(--kitbag-orange-700-75); --vp-c-brand-1: var(--kitbag-orange-500); --vp-c-brand-2: var(--kitbag-orange-700); --vp-home-hero-name-color: transparent; --vp-home-hero-name-background: -webkit-linear-gradient( 120deg, var(--kitbag-orange-300) 30%, var(--kitbag-orange-700) ); --vp-home-hero-image-background-image: linear-gradient( -45deg, var(--kitbag-orange-300) 50%, var(--kitbag-orange-700) 50% ); --vp-home-hero-image-filter: blur(40px); } a.alt.stackblitz { display: flex; align-items: center; color: rgb(19, 137, 253); gap: 0.2rem; } a.alt.stackblitz::after { display: block; height: 14px; width: 14px; content: ''; background-image: url('/stackblitz.svg'); } /* BitterBrains banner offset */ html.has-bb-banner.bb-type-top { --vp-layout-top-height: 72px; } html.has-bb-banner.bb-type-top.bb-banner-hidden { --vp-layout-top-height: 0px; } ================================================ FILE: docs/advanced-concepts/hooks.md ================================================ # Hooks Hooks offer a way to register callbacks on router lifecycle events. ```ts onAfterRouteEnter: (to, context) => { ... } ``` ## Lifecycle ### Before Hooks - **onBeforeRouteEnter:** Triggered before a route gets mounted. - **onBeforeRouteUpdate:** Triggered before a route changes. Specifically when the route changed but that parent or child didn’t. - **onBeforeRouteLeave:** Triggered before a route is about to be unmounted ### After Hooks - **onAfterRouteLeave:** Triggered after a route gets unmounted. - **onAfterRouteUpdate:** Triggered after a route changes. Specifically when the route changed but that parent or child didn’t. - **onAfterRouteEnter:** Triggered after a route is mounted ### On Error - **onError** Triggered whenever an unexpected error is thrown. Error hooks are run in the order they were registered. The hook is provided both the error and the [error context](/advanced-concepts/hooks#error-context). ### On Rejection - **onRejection** Triggered whenever a rejection is triggered. Rejection hooks are run in the order they were registered. The hook is provided both the rejection and the [rejection context](/advanced-concepts/hooks#rejection-context). ## Context The router provides `to` and a `context` argument to your hook callback. The context will always include: | Property | Description | | ---- | ---- | | from | What was the route prior to the hook's execution | | push | Convenient way to move the user from wherever they were to a new route. | | replace | Same as push, but with `options: { replace: true }`. | | reject | Trigger a [rejection](/advanced-concepts/rejections) for the router to handle | If the hooks lifecycle is a [before](/advanced-concepts/hooks#before-hooks) hook, you'll also have access to the following property in your context: | Property | Description | | ---- | ---- | | abort | Stops the router from continuing with route change | ### Error Context If the hook is `onError`, you'll also have access to the following properties in your context: | Property | Description | | ---- | ---- | | to | What was the destination route prior to the error being thrown | | source | String value indicating where the error occurred. Possible values are `'props'`, `'hook'`, and `'component'` | ### Rejection Context If the hook is `onRejection`, you'll also have access to the following properties in your context: | Property | Description | | ---- | ---- | | to | What was the destination route prior to the rejection being triggered | | from | What was the route prior to the rejection being triggered | ## Levels Hooks can be registered **globally**, on your **route**, or from within a **component**. This is useful for both providing the most convenient devx, but also can be a useful tool for ensuring proper execution order of your business logic. ### Execution Order 1. Global before hooks 2. Route before hooks 3. Component before hooks 4. Component after hooks 5. Route after hooks 6. Global after hooks ### Global ```ts router.onAfterRouteEnter((to, context) => { ... }) ``` ### Route ```ts route.onAfterRouteEnter((to, context) => { ... }) ``` ### Rejection ```ts rejection.onRejection((rejection, context) => { ... }) ``` ### Component In order to register a hook from within a component, you must use the [composition API](https://vuejs.org/guide/extras/composition-api-faq.html#composition-api-faq). ```ts import { onBeforeRouteLeave } from '@kitbag/router' onAfterRouteEnter((to, context) => { ... }) ``` :::warning You cannot register `onBeforeRouteEnter` or `onAfterRouteEnter` hooks from within a component, since the component must have been mounted to discover the hook. ::: ## Global Injection Hooks are run within the context of the Vue app the router is installed. This means you can use vue's `inject` function to access global values. ```ts import { inject } from 'vue' router.onAfterRouteEnter(() => { const value = inject('global') ... }) ``` ================================================ FILE: docs/advanced-concepts/plugins.md ================================================ # Plugins Plugins are a way to extend the router with additional functionality. They are used to add routes and rejections to the router. ```ts import { createRouterPlugin } from '@kitbag/router' const routes = [ createRoute({ name: 'home', path: '/' }), ] as const const rejections = [ createRejection({ type: 'RequiresAuth' }), ] as const const plugin = createRouterPlugin({ routes, rejections, }) ``` ## Hooks Plugins can also define global [Hooks](/advanced-concepts/hooks). ```ts plugin.onBeforeRouteEnter(() => { console.log('before route enter') }) ``` ================================================ FILE: docs/advanced-concepts/prefetching.md ================================================ # Prefetching Prefetching is a powerful feature in Kitbag Router that allows your application to start loading dependencies before users navigate, improving user experience by reducing the wait time when navigating. ## Prefetching Components When your route component is defined using `defineAsyncComponent`, Kitbag Router can start fetching that component asynchronously before it is needed. ```ts import { defineAsyncComponent } from 'vue' const user = createRoute({ name: 'user', path: '/user/[id]', component: defineAsyncComponent(() => import('./UserPage.vue')), // [!code focus] }) ``` ## Prefetching Props When your route uses the [props callback](/core-concepts/component-props), Kitbag Router can start fetching your component props before they are needed. ```ts {5-9} const user = createRoute({ name: 'user', path: '/user/[id]', component: defineAsyncComponent(() => import('./UserPage.vue')), }, async (({ id }) => { const user = await userStore.getById(id) return { user } }) ``` ::: info Props for routes and any parent routes are collected concurrently while components are being mounted. This avoids a waterfall from happening for async props. ::: ## How Prefetching Works Prefetching is handled automatically when using the `router-link` component or the `useLink` composable based on the prefetch strategy determined for that specific link. ## Prefetch Strategies The following prefetch strategies are supported: - `eager` - Prefetch immediately when the link is rendered. - `lazy` - Prefetch when the link is visible in the viewport. - `intent` - Prefetch when the link is focused or hovered. ## Configuration Prefetching can be configured at various levels. Each nested layer **overrides** the parent configuration. - Global Configuration - Per-Route Configuration - Per-Link Configuration This means that if prefetching is enabled globally, but disabled for a specific route, that route will not prefetch. Conversely, if prefetching is disabled globally, but enabled for a specific route, that route will prefetch. Prefetching can be configured with a `boolean`, a `PrefetchStrategy`, or a `PrefetchConfigOptions` object. ::: code-group ```ts [boolean] prefetch: true ``` ```ts [PrefetchStrategy] prefetch: 'lazy' ``` ```ts [PrefetchConfigOptions] prefetch: { components: 'eager', props: false, } ``` ::: ::: info If the prefetch configuration is `true`, Kitbag Router will look at any overriden prefetch configs for a strategy. If no stragety is configured the default strategy `lazy` is used. ::: ### Global Configuration By default, prefetching components is enabled and prefetching props is disabled. However, you can modify prefetching globally in your router instance by setting the `options.prefetch` property. ```ts import { createRouter } from 'kitbag-router'; const router = createRouter({ options: { prefetch: false, // all prefetching is disabled by default }, }); ``` ### Per-Route Configuration If you want to enable or disable prefetching for specific routes, you can do so by adding a prefetch property to your route definition. ```ts const about = createRoute({ path: '/about', component: () => import('./About.vue'), prefetch: true, // enable prefetching for this route }) const contact = createRoute({ path: '/contact', component: () => import('./Contact.vue'), prefetch: false, // disable prefetching for this route }) ``` ### Per-Link Configuration You can also control prefetching at the level of individual router-links by passing a prefetch prop. ```html About Us About Us Contact Us ``` Similarly, when using the `useLink` composable, you can pass a prefetch option. ```ts import { useLink } from 'kitbag-router'; const link = useLink({ to: '/about', prefetch: true, // enable prefetching for this link }); ``` ================================================ FILE: docs/advanced-concepts/redirects.md ================================================ # Redirects Redirects provide a declarative way to automatically redirect from one route to another. This is useful for handling URL migrations or implementing redirect logic based on route parameters. ## Route Redirects Route redirects allow you to define redirect relationships between routes. When a user navigates to a route that has a redirect configured, the router will automatically redirect them to the target route. There are two methods available on route objects for creating redirects: - **`redirectTo`**: Redirects the current route to another route - **`redirectFrom`**: Redirects from another route to the current route ## Basic Usage ### Using `redirectTo` The `redirectTo` method is called on the source route (the route you want to redirect from) and takes the destination route as an argument. ```ts import { createRoute, createRouter } from '@kitbag/router' const home = createRoute({ name: 'home', path: '/home', }) const dashboard = createRoute({ name: 'dashboard', path: '/dashboard', }) // Redirect from 'home' to 'dashboard' home.redirectTo(dashboard) ``` ### Using `redirectFrom` The `redirectFrom` method is called on the destination route (the route you want to redirect to) and takes the source route as an argument. ```ts import { createRoute, createRouter } from '@kitbag/router' const home = createRoute({ name: 'home', path: '/home', }) const dashboard = createRoute({ name: 'dashboard', path: '/dashboard', }) // Redirect from 'home' to 'dashboard' dashboard.redirectFrom(home) ``` Both approaches achieve the same result. Choose the one that feels more natural for your use case: - Use `redirectTo` when you're working with the source route and want to specify where it should redirect - Use `redirectFrom` when you're working with the destination route and want to specify which routes should redirect to it ## Parameters When redirecting between routes with parameters, you can provide a callback function to convert parameters from the source route to the destination route. ### Converting Path Parameters ```ts import { createRoute, createRouter } from '@kitbag/router' const oldPost = createRoute({ name: 'old-post', path: '/blog/[blogId]', }) const newPost = createRoute({ name: 'new-post', path: '/articles/[articleId]', }) // Convert 'blogId' parameter to 'articleId' oldPost.redirectTo(newPost, ({ blogId }) => { return { articleId: blogId } }) ``` ## Restrictions ### One Redirect Per Route Each route can only have one redirect configured. Attempting to configure multiple redirects for the same route will throw a `MultipleRouteRedirectsError`. ```ts import { createRoute } from '@kitbag/router' const routeA = createRoute({ name: 'a', path: '/a' }) const routeB = createRoute({ name: 'b', path: '/b' }) const routeC = createRoute({ name: 'c', path: '/c' }) routeA.redirectTo(routeB) // This will throw MultipleRouteRedirectsError routeA.redirectTo(routeC) // ❌ Error (routeA already redirects to routeB) routeB.redirectFrom(routeA) // ❌ Error (routeA already redirects to routeB) ``` ================================================ FILE: docs/advanced-concepts/rejections.md ================================================ # Rejections Kitbag Router ships with built in support for rejection handling. Each rejection type you need is registered with a corresponding view, if rejections happen at any point in the router lifecycle the router will handle it. ## Rejection Types Creating custom rejections is extremely easily with the [`createRejection`](/api/functions/createRejection) utility. For example, we can add a custom rejection for "AuthNeeded". ```ts import { createRejection, createRouter } from '@kitbag/router' const authNeededRejection = createRejection({ type: 'AuthNeeded', }) export const router = createRouter(routes, { rejections: [authNeededRejection] }) ``` ## Rejection Component When a rejection happens, Kitbag router mounts whatever component you passed in to the `component` property. If no component was assigned, Kitbag router will use the default rejection component that ships with Kitbag Router. ```ts {1,5} import LoginView from '@/views/LoginView.vue' const authNeededRejection = createRejection({ type: 'AuthNeeded', component: LoginView, }) ``` Now if your `AuthNeeded` rejection is triggered, the `LoginView` component will be mounted. ## Built-In Rejections Every new router has a `NotFound` rejection by default. This enables the default behavior of the router when it's given a URL that doesn't match any of your routes. Instead of the user having to define a 404/catch-all route, Kitbag router solves this problem for you. ### Overriding Built-In Rejection Components You'll probably want to provide your own component to display to users when they end up somewhere unexpected. To override the default behavior, simply create a new rejection with the same name that includes your desired component. ```ts import NotFoundPage from '@/components/NotFoundPage.vue' const notFoundRejection = createRejection({ type: 'NotFound', component: NotFoundPage, }) export const router = createRouter(routes, { rejections: [notFoundRejection] }) ``` ## Trigger Rejection Any of the [hooks](/advanced-concepts/hooks) will provided a `reject` function in the context argument. The [async prop function](/core-concepts/component-props#async-prop-fetching) argument of `createRoute` also will provide a `reject` function. ```ts route.onBeforeRouteEnter((to, { reject }) => { reject('AuthNeeded') }) ``` Alternatively, you can always trigger a rejection from `router.reject`. ```ts import { useRouter } from '@kitbag/router' const router = useRouter() function maybeAuthNeeded() { ... router.reject('AuthNeeded') } ``` ### Get Rejection Though it's uncommon, your rejection components could access to the current rejection with `useRejection` if you need it. ```ts import { useRejection } from '@kitbag/router' const rejection = useRejection() const rejectionType = computed(() => rejection.value.type) ``` ## Title The `setTitle` callback is used to set the document title for the rejection. The callback is given the resolved route and a context object. The callback can be async, and should return a string that should be set as the document.title. The context object has the following properties: | Property | Type | Description | | -------- | ---- | ----------- | | from | ResolvedRoute | The route that is being navigated from. | | getParentTitle | () => Promise | Promise that resolves to the title of the parent route. | ```ts import { createRejection } from '@kitbag/router' const authNeededRejection = createRejection({ type: 'AuthNeeded', }) authNeededRejection.setTitle((to, context) => { return `Unauthorized!` }) ``` ================================================ FILE: docs/advanced-concepts/route-matching.md ================================================ # Route Matching There are several rules Kitbag Router uses to determine which of your routes corresponds to the current URL. ## Named Routes without a [name](/core-concepts/routes#name) property cannot be matched. ## External Routes If a route is defined as [external](/core-concepts/external-routes), it will only be matched when the url is also external. ## Depth A route’s depth is determined by the number of parent routes it has. The deeper the route, the higher the priority it has when matching. ```ts const external = createExternalRoute({ name: 'external', host: 'https://kitbag.dev', path: '/about-us' }) ``` :white_check_mark: `https://kitbag.dev/about-us` :x: `https://example.com/about-us` :x: `/about-us` ## Path Matches Routes `path` must match the structure of the URL pathname. ```ts const route = createRoute({ ... path: '/parent/anything/child' }) ``` :white_check_mark: `parent/anything/child` :x: `parent/123/child` :x: `parent//child` :x: `parent/child` ```ts const route = createRoute({ ... path: '/parent/[myParam]/child' }) ``` :white_check_mark: `parent/anything/child` :white_check_mark: `parent/123/child` :x: `parent//child` :x: `parent/child` ```ts const route = createRoute({ ... path: '/parent/[?myParam]/child' }) ``` :white_check_mark: `parent/anything/child` :white_check_mark: `parent/123/child` :white_check_mark: `parent//child` :x: `parent/child` ## Query Matches Routes `query` must match the structure of the URL search. ```ts const route = createRoute({ ... query: { foo: 'bar', }, }) ``` :white_check_mark: `?foo=bar` :white_check_mark: `?kitbag=cat&foo=bar` :x: `?foo=123` :x: `?foo` ```ts const route = createRoute({ ... query: { foo: String, }, }) ``` :white_check_mark: `?foo=bar` :white_check_mark: `?kitbag=cat&foo=bar` :white_check_mark: `?foo=123` :x: `?foo` ```ts const route = createRoute({ ... query: { '?foo': String, }, }) ``` :white_check_mark: `?foo=bar` :white_check_mark: `?kitbag=cat&foo=bar` :white_check_mark: `?foo=123` :white_check_mark: `?foo` ::: tip when your query param is optional, the entire property can be missing and the route will still match. For the example above with query `foo=[?bar]`, the url might be `/my-route` without any query, or it might have an unrelated query `/my-route?other=value`, and still be a match because the entire foo param is optional. ::: ## Params Are Valid Assuming a route's path and query match the structure of the URL, the last test is to make sure that values provided by the URL pass the Param parsing. By default params are assumed to be strings, so by default if structure matches, parsing will pass as well since the URL is a string. However, if you define your params with `Boolean`, `Number`, `Date`, `JSON`, or a custom `Param` the value will be need to pass the param's `get` function. ```ts const route = createRoute({ ... path: '/parent/[id]' query: { '?tab': String, }, }) ``` :white_check_mark: `parent/123` :white_check_mark: `parent/123?tab=true` :white_check_mark: `parent/123?tab=github` :white_check_mark: `parent/ABC?tab=true` ```ts const route = createRoute({ ... path: withParams('/parent/[id]', { id: Number }) query: { '?tab': String, }, }) ``` :white_check_mark: `parent/123` :white_check_mark: `parent/123?tab=true` :white_check_mark: `parent/123?tab=github` :x: `parent/ABC?tab=true` ```ts const route = createRoute({ ... path: withParams('/parent/[id]', { id: Number }) query: withParams('tab=[?tab]', { tab: Boolean }) }) ``` :white_check_mark: `parent/123` :white_check_mark: `parent/123?tab=true` :x: `parent/123?tab=github` :x: `parent/ABC?tab=true` ================================================ FILE: docs/advanced-concepts/route-meta.md ================================================ # Route Meta It may be useful to store additional information about your route to be used in a hook, or within a component. Meta data might be useful for authorization, analytics, and much more. For example, below we will use route meta to configure the document title per route. ```ts import { createRoute, createRouter } from '@kitbag/router' const routes = [ createRoute({ name: 'home', path: '/', component: Home, meta: { pageTitle: 'Kitbag Home' } }), createRoute({ name: 'path', path: '/about', component: About, meta: { pageTitle: 'Learn More About Kitbag' } }), ] as const const router = createRouter(routes) router.onAfterRouteEnter(to => { document.title = to.matched.meta.pageTitle }) ``` ## Meta Type Types for meta defined on individual routes will just work. If you want to require certain properties be set on all routes, you can update the global type with [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html). ```ts declare module '@kitbag/router' { interface Register { routeMeta: { pageTitle?: string } } } ``` ## Cascading Meta Meta will automatically cascade from parent routes down through child routes. ```ts const parent = createRoute({ name: 'parent', meta: { public: true } }) const child = createRoute({ parent, name: 'child', }) // child has 'public: true' child.meta.public ``` ### Meta Property Conflict Unlike other cascading properties like params, a child **can** also define duplicate keys in meta. However, in order for the types to be accurate child properties must match the `typeof` on the parent meta. When the router finds a duplicate key with conflicting types it will throw a [MetaPropertyConflict](/api/errors/MetaPropertyConflict) error. ================================================ FILE: docs/advanced-concepts/route-narrowing.md ================================================ # Route Narrowing When accessing the current route or using the `useRoute` composable, by default the type is a union of all possible routes. ```ts import { createRoute, createRouter } from '@kitbag/router' const home = createRoute({ name: 'home', path: '/', component: ..., }) const user = createRoute({ name: 'user', path: '/user/[userId]', component: ..., }) const profile = createRoute({ parent: user, name: 'user.profile', path: '/profile', query: '?tab=[tab]', component: ..., }) const settings = createRoute({ parent: user, name: 'user.settings', path: '/settings', component: ..., }) const router = createRouter([home, user, profile, settings]) router.route.name // "home" | "user" | "user.profile" | "user.settings" ``` This can be narrowed like any union in Typescript, by checking the route name. ```ts if(router.route.name === 'user') { router.route.name // "user" router.route.params // { userId: string } } ``` You can also use the `isRoute` type guard. You could write the same logic as above like this. ```ts import { isRoute } from '@kitbag/router' if(isRoute(router.route, 'user', { exact: true })) { router.route.name // "user" router.route.params // { userId: string } } ``` The `isRoute` type guard offers more flexibility with the optional `exact` argument, which defaults to `false` and will return narrow to the target route or any of it's descendants. ```ts import { isRoute } from '@kitbag/router' if(isRoute(router.route, 'user', { exact: false })) { router.route.name // "user" | "user.profile" | "user.settings" router.route.params // { userId: string } | { userId: string, tab: string } } ``` ================================================ FILE: docs/advanced-concepts/route-state.md ================================================ # Route State It may be useful to store state for a given route to improve your user's experience. In situations like a form that a user might fill out, it might be useful to store form values in the [browser state](https://developer.mozilla.org/en-US/docs/Web/API/History/state) so that if the user navigates unexpectedly the values can be restored when going back. Kitbag Router extends this functionality by offering the same [param experience](/core-concepts/params#param-types) on state as well. ```ts import { createRoute } from '@kitbag/router' const route = createRoute({ name: 'example-form', state: { email: String, active: Boolean, } }) ``` ## Always Optional State properties are always expected to be optional. The only exception to this is when your state property is defined with the `withDefault` utility. ```ts const route = createRoute({ name: 'example-form', state: { email: String, active: Boolean, // [!code --] active: withDefault(Boolean, false), // [!code ++] } }) ``` ## Reading State Accessing the runtime values can be found on a route's `state` property ```ts const route = useRoute('example-form') route.state.email ``` ## Writing State There is a `state` property on router `push` and `replace`, which can be used to set state according to the routes state params. ```ts router.push('example-form', {}, { email: 'mittens@kitbag.dev', }) ``` The state values on the route are **writable**. This means that you can just modify the values directly and Kitbag Router will handle the communication with browser to update history state. You can use `route.update()` method which takes the same properties as `push()`. ================================================ FILE: docs/api/components/RouterLink.md ================================================ # Components: RouterLink ```ts const RouterLink: RouterAssets["RouterLink"]; ``` A component to render a link to a route or any url. ## Param The props to pass to the router link component. ## Returns The router link component. ================================================ FILE: docs/api/components/RouterView.md ================================================ # Components: RouterView ```ts const RouterView: RouterAssets["RouterView"]; ``` A component to render the current route's component. ## Param The props to pass to the router view component. ## Returns The router view component. ================================================ FILE: docs/api/compositions/useLink.md ================================================ # Compositions: useLink ```ts const useLink: RouterAssets["useLink"]; ``` A composition to export much of the functionality that drives RouterLink component. Also exports some useful context about routes relationship to current URL and convenience methods for navigating. ## Param The name of the route or a valid URL. ## Param If providing route name, this argument will expect corresponding params. ## Param [RouterResolveOptions](../types/RouterResolveOptions.md) Same options as router resolve. ## Returns Reactive context values for as well as navigation methods. ================================================ FILE: docs/api/compositions/useQueryValue.md ================================================ # Compositions: useQueryValue ```ts const useQueryValue: RouterAssets["useQueryValue"]; ``` A composition to access a specific query value from the current route. ## Returns The query value from the router. ================================================ FILE: docs/api/compositions/useRejection.md ================================================ # Compositions: useRejection ```ts const useRejection: RouterAssets["useRejection"]; ``` A composition to access the rejection from the router. ## Returns The rejection from the router. ================================================ FILE: docs/api/compositions/useRoute.md ================================================ # Compositions: useRoute ```ts const useRoute: RouterAssets["useRoute"]; ``` A composition to access the current route or verify a specific route name within a Vue component. This function provides two overloads: 1. When called without arguments, it returns the current route from the router without types. 2. When called with a route name, it checks if the current active route includes the specified route name. The function also sets up a reactive watcher on the route object from the router to continually check the validity of the route name if provided, throwing an error if the validation fails at any point during the component's lifecycle. ## Template A string type that should match route name of the registered router, ensuring the route name exists. ## Param Optional. The name of the route to validate against the current active routes. ## Returns The current router route. If a route name is provided, it validates the route name first. ## Throws Throws an error if the provided route name is not valid or does not match the current route. ================================================ FILE: docs/api/compositions/useRouter.md ================================================ # Compositions: useRouter ```ts const useRouter: RouterAssets["useRouter"]; ``` A composition to access the installed router instance within a Vue component. ## Returns The installed router instance. ## Throws Throws an error if the router has not been installed, ensuring the component does not operate without routing functionality. ================================================ FILE: docs/api/errors/DuplicateParamsError.md ================================================ # Errors: DuplicateParamsError An error thrown when duplicate parameters are detected in a route. Param names must be unique. This includes params defined in a path parent and params defined in the query. ## Extends - `Error` ## Constructors ### Constructor ```ts new DuplicateParamsError(paramName): DuplicateParamsError; ``` Constructs a new DuplicateParamsError instance with a message indicating the problematic parameter. #### Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `paramName` | `string` | The name of the parameter that was duplicated. | #### Returns `DuplicateParamsError` #### Overrides ```ts Error.constructor ``` ## Methods ### captureStackTrace() ```ts static captureStackTrace(targetObject, constructorOpt?): void; ``` Creates a `.stack` property on `targetObject`, which when accessed returns a string representing the location in the code at which `Error.captureStackTrace()` was called. ```js const myObject = {}; Error.captureStackTrace(myObject); myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with `${myObject.name}: ${myObject.message}`. The optional `constructorOpt` argument accepts a function. If given, all frames above `constructorOpt`, including `constructorOpt`, will be omitted from the generated stack trace. The `constructorOpt` argument is useful for hiding implementation details of error generation from the user. For instance: ```js function a() { b(); } function b() { c(); } function c() { // Create an error without stack trace to avoid calculating the stack trace twice. const { stackTraceLimit } = Error; Error.stackTraceLimit = 0; const error = new Error(); Error.stackTraceLimit = stackTraceLimit; // Capture the stack trace above function b Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace throw error; } a(); ``` #### Parameters | Parameter | Type | | ------ | ------ | | `targetObject` | `object` | | `constructorOpt?` | `Function` | #### Returns `void` #### Inherited from ```ts Error.captureStackTrace ``` *** ### isError() ```ts static isError(error): error is Error; ``` Indicates whether the argument provided is a built-in Error instance or not. #### Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | #### Returns `error is Error` #### Inherited from ```ts Error.isError ``` *** ### prepareStackTrace() ```ts static prepareStackTrace(err, stackTraces): any; ``` #### Parameters | Parameter | Type | | ------ | ------ | | `err` | `Error` | | `stackTraces` | `CallSite`[] | #### Returns `any` #### See https://v8.dev/docs/stack-trace-api#customizing-stack-traces #### Inherited from ```ts Error.prepareStackTrace ``` ## Properties | Property | Modifier | Type | Description | Inherited from | | ------ | ------ | ------ | ------ | ------ | | `cause?` | `public` | `unknown` | - | `Error.cause` | | `message` | `public` | `string` | - | `Error.message` | | `name` | `public` | `string` | - | `Error.name` | | `stack?` | `public` | `string` | - | `Error.stack` | | `stackTraceLimit` | `static` | `number` | The `Error.stackTraceLimit` property specifies the number of stack frames collected by a stack trace (whether generated by `new Error().stack` or `Error.captureStackTrace(obj)`). The default value is `10` but may be set to any valid JavaScript number. Changes will affect any stack trace captured _after_ the value has been changed. If set to a non-number value, or set to a negative number, stack traces will not capture any frames. | `Error.stackTraceLimit` | ================================================ FILE: docs/api/errors/MetaPropertyConflict.md ================================================ # Errors: MetaPropertyConflict An error thrown when a parent's meta has the same key as a child and the types are not compatible. A child's meta can override properties of the parent, however the types must match! ## Extends - `Error` ## Constructors ### Constructor ```ts new MetaPropertyConflict(property?): MetaPropertyConflict; ``` #### Parameters | Parameter | Type | | ------ | ------ | | `property?` | `string` | #### Returns `MetaPropertyConflict` #### Overrides ```ts Error.constructor ``` ## Methods ### captureStackTrace() ```ts static captureStackTrace(targetObject, constructorOpt?): void; ``` Creates a `.stack` property on `targetObject`, which when accessed returns a string representing the location in the code at which `Error.captureStackTrace()` was called. ```js const myObject = {}; Error.captureStackTrace(myObject); myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with `${myObject.name}: ${myObject.message}`. The optional `constructorOpt` argument accepts a function. If given, all frames above `constructorOpt`, including `constructorOpt`, will be omitted from the generated stack trace. The `constructorOpt` argument is useful for hiding implementation details of error generation from the user. For instance: ```js function a() { b(); } function b() { c(); } function c() { // Create an error without stack trace to avoid calculating the stack trace twice. const { stackTraceLimit } = Error; Error.stackTraceLimit = 0; const error = new Error(); Error.stackTraceLimit = stackTraceLimit; // Capture the stack trace above function b Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace throw error; } a(); ``` #### Parameters | Parameter | Type | | ------ | ------ | | `targetObject` | `object` | | `constructorOpt?` | `Function` | #### Returns `void` #### Inherited from ```ts Error.captureStackTrace ``` *** ### isError() ```ts static isError(error): error is Error; ``` Indicates whether the argument provided is a built-in Error instance or not. #### Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | #### Returns `error is Error` #### Inherited from ```ts Error.isError ``` *** ### prepareStackTrace() ```ts static prepareStackTrace(err, stackTraces): any; ``` #### Parameters | Parameter | Type | | ------ | ------ | | `err` | `Error` | | `stackTraces` | `CallSite`[] | #### Returns `any` #### See https://v8.dev/docs/stack-trace-api#customizing-stack-traces #### Inherited from ```ts Error.prepareStackTrace ``` ## Properties | Property | Modifier | Type | Description | Inherited from | | ------ | ------ | ------ | ------ | ------ | | `cause?` | `public` | `unknown` | - | `Error.cause` | | `message` | `public` | `string` | - | `Error.message` | | `name` | `public` | `string` | - | `Error.name` | | `stack?` | `public` | `string` | - | `Error.stack` | | `stackTraceLimit` | `static` | `number` | The `Error.stackTraceLimit` property specifies the number of stack frames collected by a stack trace (whether generated by `new Error().stack` or `Error.captureStackTrace(obj)`). The default value is `10` but may be set to any valid JavaScript number. Changes will affect any stack trace captured _after_ the value has been changed. If set to a non-number value, or set to a negative number, stack traces will not capture any frames. | `Error.stackTraceLimit` | ================================================ FILE: docs/api/errors/RouterNotInstalledError.md ================================================ # Errors: RouterNotInstalledError An error thrown when an attempt is made to use routing functionality before the router has been installed. ## Extends - `Error` ## Constructors ### Constructor ```ts new RouterNotInstalledError(): RouterNotInstalledError; ``` #### Returns `RouterNotInstalledError` #### Overrides ```ts Error.constructor ``` ## Methods ### captureStackTrace() ```ts static captureStackTrace(targetObject, constructorOpt?): void; ``` Creates a `.stack` property on `targetObject`, which when accessed returns a string representing the location in the code at which `Error.captureStackTrace()` was called. ```js const myObject = {}; Error.captureStackTrace(myObject); myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with `${myObject.name}: ${myObject.message}`. The optional `constructorOpt` argument accepts a function. If given, all frames above `constructorOpt`, including `constructorOpt`, will be omitted from the generated stack trace. The `constructorOpt` argument is useful for hiding implementation details of error generation from the user. For instance: ```js function a() { b(); } function b() { c(); } function c() { // Create an error without stack trace to avoid calculating the stack trace twice. const { stackTraceLimit } = Error; Error.stackTraceLimit = 0; const error = new Error(); Error.stackTraceLimit = stackTraceLimit; // Capture the stack trace above function b Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace throw error; } a(); ``` #### Parameters | Parameter | Type | | ------ | ------ | | `targetObject` | `object` | | `constructorOpt?` | `Function` | #### Returns `void` #### Inherited from ```ts Error.captureStackTrace ``` *** ### isError() ```ts static isError(error): error is Error; ``` Indicates whether the argument provided is a built-in Error instance or not. #### Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | #### Returns `error is Error` #### Inherited from ```ts Error.isError ``` *** ### prepareStackTrace() ```ts static prepareStackTrace(err, stackTraces): any; ``` #### Parameters | Parameter | Type | | ------ | ------ | | `err` | `Error` | | `stackTraces` | `CallSite`[] | #### Returns `any` #### See https://v8.dev/docs/stack-trace-api#customizing-stack-traces #### Inherited from ```ts Error.prepareStackTrace ``` ## Properties | Property | Modifier | Type | Description | Inherited from | | ------ | ------ | ------ | ------ | ------ | | `cause?` | `public` | `unknown` | - | `Error.cause` | | `message` | `public` | `string` | - | `Error.message` | | `name` | `public` | `string` | - | `Error.name` | | `stack?` | `public` | `string` | - | `Error.stack` | | `stackTraceLimit` | `static` | `number` | The `Error.stackTraceLimit` property specifies the number of stack frames collected by a stack trace (whether generated by `new Error().stack` or `Error.captureStackTrace(obj)`). The default value is `10` but may be set to any valid JavaScript number. Changes will affect any stack trace captured _after_ the value has been changed. If set to a non-number value, or set to a negative number, stack traces will not capture any frames. | `Error.stackTraceLimit` | ================================================ FILE: docs/api/errors/UseRouteInvalidError.md ================================================ # Errors: UseRouteInvalidError An error thrown when there is a mismatch between an expected route and the one actually used. ## Extends - `Error` ## Constructors ### Constructor ```ts new UseRouteInvalidError(routeName, actualRouteName): UseRouteInvalidError; ``` Constructs a new UseRouteInvalidError instance with a message that specifies both the given and expected route names. This detailed error message aids in quickly identifying and resolving mismatches in route usage. #### Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `routeName` | `string` | The route name that was incorrectly used. | | `actualRouteName` | `string` | The expected route name that should have been used. | #### Returns `UseRouteInvalidError` #### Overrides ```ts Error.constructor ``` ## Methods ### captureStackTrace() ```ts static captureStackTrace(targetObject, constructorOpt?): void; ``` Creates a `.stack` property on `targetObject`, which when accessed returns a string representing the location in the code at which `Error.captureStackTrace()` was called. ```js const myObject = {}; Error.captureStackTrace(myObject); myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with `${myObject.name}: ${myObject.message}`. The optional `constructorOpt` argument accepts a function. If given, all frames above `constructorOpt`, including `constructorOpt`, will be omitted from the generated stack trace. The `constructorOpt` argument is useful for hiding implementation details of error generation from the user. For instance: ```js function a() { b(); } function b() { c(); } function c() { // Create an error without stack trace to avoid calculating the stack trace twice. const { stackTraceLimit } = Error; Error.stackTraceLimit = 0; const error = new Error(); Error.stackTraceLimit = stackTraceLimit; // Capture the stack trace above function b Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace throw error; } a(); ``` #### Parameters | Parameter | Type | | ------ | ------ | | `targetObject` | `object` | | `constructorOpt?` | `Function` | #### Returns `void` #### Inherited from ```ts Error.captureStackTrace ``` *** ### isError() ```ts static isError(error): error is Error; ``` Indicates whether the argument provided is a built-in Error instance or not. #### Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | #### Returns `error is Error` #### Inherited from ```ts Error.isError ``` *** ### prepareStackTrace() ```ts static prepareStackTrace(err, stackTraces): any; ``` #### Parameters | Parameter | Type | | ------ | ------ | | `err` | `Error` | | `stackTraces` | `CallSite`[] | #### Returns `any` #### See https://v8.dev/docs/stack-trace-api#customizing-stack-traces #### Inherited from ```ts Error.prepareStackTrace ``` ## Properties | Property | Modifier | Type | Description | Inherited from | | ------ | ------ | ------ | ------ | ------ | | `cause?` | `public` | `unknown` | - | `Error.cause` | | `message` | `public` | `string` | - | `Error.message` | | `name` | `public` | `string` | - | `Error.name` | | `stack?` | `public` | `string` | - | `Error.stack` | | `stackTraceLimit` | `static` | `number` | The `Error.stackTraceLimit` property specifies the number of stack frames collected by a stack trace (whether generated by `new Error().stack` or `Error.captureStackTrace(obj)`). The default value is `10` but may be set to any valid JavaScript number. Changes will affect any stack trace captured _after_ the value has been changed. If set to a non-number value, or set to a negative number, stack traces will not capture any frames. | `Error.stackTraceLimit` | ================================================ FILE: docs/api/functions/arrayOf.md ================================================ # Functions: arrayOf() ```ts function arrayOf(params, options?): ParamGetSet[]>; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* [`Param`](../types/Param.md)[] | ## Parameters | Parameter | Type | | ------ | ------ | | `params` | `T` | | `options?` | `ArrayOfOptions` | ## Returns [`ParamGetSet`](../types/ParamGetSet.md)\<`ExtractParamType`\<`T`\[`number`\]\>[]\> ================================================ FILE: docs/api/functions/asUrlString.md ================================================ # Functions: asUrlString() ```ts function asUrlString(value): UrlString; ``` Converts a string to a valid URL. ## Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `value` | `string` | The string to convert. | ## Returns [`UrlString`](../types/UrlString.md) The valid URL. ================================================ FILE: docs/api/functions/combineRoutes.md ================================================ # Functions: combineRoutes() ```ts function combineRoutes(parent, child): Route; ``` ## Parameters | Parameter | Type | | ------ | ------ | | `parent` | [`Route`](../types/Route.md) | | `child` | [`Route`](../types/Route.md) | ## Returns [`Route`](../types/Route.md) ================================================ FILE: docs/api/functions/createExternalRoute.md ================================================ # Functions: createExternalRoute() ## Call Signature ```ts function createExternalRoute(options): ToRoute & ExternalRouteHooks, TOptions["context"]> & RouteRedirects>; ``` ### Type Parameters | Type Parameter | | ------ | | `TOptions` *extends* [`CreateRouteOptions`](../types/CreateRouteOptions.md) & [`WithHost`](../types/WithHost.md) & [`WithoutParent`](../types/WithoutParent.md) | ### Parameters | Parameter | Type | | ------ | ------ | | `options` | `TOptions` | ### Returns [`ToRoute`](../types/ToRoute.md)\<`TOptions`\> & [`ExternalRouteHooks`](../types/ExternalRouteHooks.md)\<[`ToRoute`](../types/ToRoute.md)\<`TOptions`\>, `TOptions`\[`"context"`\]\> & `RouteRedirects`\<[`ToRoute`](../types/ToRoute.md)\<`TOptions`\>\> ## Call Signature ```ts function createExternalRoute(options): ToRoute & ExternalRouteHooks, ExtractRouteContext> & RouteRedirects>; ``` ### Type Parameters | Type Parameter | | ------ | | `TOptions` *extends* [`CreateRouteOptions`](../types/CreateRouteOptions.md) & [`WithoutHost`](../types/WithoutHost.md) & [`WithParent`](../types/WithParent.md) | ### Parameters | Parameter | Type | | ------ | ------ | | `options` | `TOptions` | ### Returns [`ToRoute`](../types/ToRoute.md)\<`TOptions`\> & [`ExternalRouteHooks`](../types/ExternalRouteHooks.md)\<[`ToRoute`](../types/ToRoute.md)\<`TOptions`\>, `ExtractRouteContext`\<`TOptions`\>\> & `RouteRedirects`\<[`ToRoute`](../types/ToRoute.md)\<`TOptions`\>\> ================================================ FILE: docs/api/functions/createParam.md ================================================ # Functions: createParam() ## Call Signature ```ts function createParam(param): TParam; ``` ### Type Parameters | Type Parameter | | ------ | | `TParam` *extends* `Required`\<[`ParamGetSet`](../types/ParamGetSet.md)\<`unknown`\>\> | ### Parameters | Parameter | Type | | ------ | ------ | | `param` | `TParam` | ### Returns `TParam` ## Call Signature ```ts function createParam(param): ParamGetSet>; ``` ### Type Parameters | Type Parameter | | ------ | | `TParam` *extends* [`Param`](../types/Param.md) | ### Parameters | Parameter | Type | | ------ | ------ | | `param` | `TParam` | ### Returns [`ParamGetSet`](../types/ParamGetSet.md)\<`ExtractParamType`\<`TParam`\>\> ## Call Signature ```ts function createParam(param, defaultValue): ParamWithDefault; ``` ### Type Parameters | Type Parameter | | ------ | | `TParam` *extends* [`Param`](../types/Param.md) | ### Parameters | Parameter | Type | | ------ | ------ | | `param` | `TParam` | | `defaultValue` | `NoInfer`\<`ExtractParamType`\<`TParam`\>\> | ### Returns `ParamWithDefault`\<`TParam`\> ================================================ FILE: docs/api/functions/createRejection.md ================================================ # Functions: createRejection() ```ts function createRejection(options): Rejection; ``` ## Type Parameters | Type Parameter | | ------ | | `TType` *extends* `string` | ## Parameters | Parameter | Type | | ------ | ------ | | `options` | \{ `component?`: `Component`; `type`: `TType`; \} | | `options.component?` | `Component` | | `options.type` | `TType` | ## Returns `Rejection`\<`TType`\> ================================================ FILE: docs/api/functions/createRoute.md ================================================ # Functions: createRoute() ```ts function createRoute(options, ...args): ToRoute & InternalRouteHooks, ExtractRouteContext> & RouteRedirects>; ``` ## Type Parameters | Type Parameter | | ------ | | `TOptions` *extends* [`CreateRouteOptions`](../types/CreateRouteOptions.md) | | `TProps` *extends* \| [`PropsGetter`](../types/PropsGetter.md)\<`TOptions`, `any`\[`any`\]\> \| `RoutePropsRecord`\<`TOptions`, `any`\[`any`\]\> \| [`RouterViewPropsGetter`](../types/RouterViewPropsGetter.md)\<`TOptions`\> | ## Parameters | Parameter | Type | | ------ | ------ | | `options` | `TOptions` | | ...`args` | `CreateRouteWithProps`\<`TOptions`, `TProps`\> | ## Returns [`ToRoute`](../types/ToRoute.md)\<`TOptions`, `TProps`\> & [`InternalRouteHooks`](../types/InternalRouteHooks.md)\<[`ToRoute`](../types/ToRoute.md)\<`TOptions`\>, `ExtractRouteContext`\<`TOptions`\>\> & `RouteRedirects`\<[`ToRoute`](../types/ToRoute.md)\<`TOptions`\>\> ================================================ FILE: docs/api/functions/createRouter.md ================================================ # Functions: createRouter() ## Call Signature ```ts function createRouter( routes, options?, plugins?): Router; ``` Creates a router instance for a Vue application, equipped with methods for route handling, lifecycle hooks, and state management. ### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](../types/Routes.md) | - | | `TOptions` *extends* [`RouterOptions`](../types/RouterOptions.md) | `object` | | `TPlugin` *extends* [`RouterPlugin`](../types/RouterPlugin.md) | [`EmptyRouterPlugin`](../types/EmptyRouterPlugin.md) | ### Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `routes` | `TRoutes` | [Routes](../types/Routes.md) An array of route definitions specifying the configuration of routes in the application. Use createRoute method to create the route definitions. | | `options?` | `TOptions` | [RouterOptions](../types/RouterOptions.md) for the router, including history mode and initial URL settings. | | `plugins?` | `TPlugin`[] | - | ### Returns [`Router`](../types/Router.md)\<`TRoutes`, `TOptions`, `TPlugin`\> Router instance ### Example ```ts import { createRoute, createRouter } from '@kitbag/router' const Home = { template: '
Home
' } const About = { template: '
About
' } export const routes = [ createRoute({ name: 'home', path: '/', component: Home }), createRoute({ name: 'path', path: '/about', component: About }), ] as const const router = createRouter(routes) ``` ## Call Signature ```ts function createRouter( routes, options?, plugins?): Router; ``` Creates a router instance for a Vue application, equipped with methods for route handling, lifecycle hooks, and state management. ### Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](../types/Routes.md) | - | | `TOptions` *extends* [`RouterOptions`](../types/RouterOptions.md) | `object` | | `TPlugin` *extends* [`RouterPlugin`](../types/RouterPlugin.md) | [`EmptyRouterPlugin`](../types/EmptyRouterPlugin.md) | ### Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `routes` | `TRoutes`[] | [Routes](../types/Routes.md) An array of route definitions specifying the configuration of routes in the application. Use createRoute method to create the route definitions. | | `options?` | `TOptions` | [RouterOptions](../types/RouterOptions.md) for the router, including history mode and initial URL settings. | | `plugins?` | `TPlugin`[] | - | ### Returns [`Router`](../types/Router.md)\<`TRoutes`, `TOptions`, `TPlugin`\> Router instance ### Example ```ts import { createRoute, createRouter } from '@kitbag/router' const Home = { template: '
Home
' } const About = { template: '
About
' } export const routes = [ createRoute({ name: 'home', path: '/', component: Home }), createRoute({ name: 'path', path: '/about', component: About }), ] as const const router = createRouter(routes) ``` ================================================ FILE: docs/api/functions/createRouterAssets.md ================================================ # Functions: createRouterAssets() ## Call Signature ```ts function createRouterAssets(router): RouterAssets; ``` ### Type Parameters | Type Parameter | | ------ | | `TRouter` *extends* [`Router`](../types/Router.md) | ### Parameters | Parameter | Type | | ------ | ------ | | `router` | `TRouter` | ### Returns [`RouterAssets`](../types/RouterAssets.md)\<`TRouter`\> ## Call Signature ```ts function createRouterAssets(routerKey): RouterAssets; ``` ### Type Parameters | Type Parameter | | ------ | | `TRouter` *extends* [`Router`](../types/Router.md) | ### Parameters | Parameter | Type | | ------ | ------ | | `routerKey` | `InjectionKey`\<`TRouter`\> | ### Returns [`RouterAssets`](../types/RouterAssets.md)\<`TRouter`\> ================================================ FILE: docs/api/functions/createRouterPlugin.md ================================================ # Functions: createRouterPlugin() ```ts function createRouterPlugin(plugin): RouterPlugin & PluginRouteHooks; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](../types/Routes.md) | [`Routes`](../types/Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Parameters | Parameter | Type | | ------ | ------ | | `plugin` | [`CreateRouterPluginOptions`](../types/CreateRouterPluginOptions.md)\<`TRoutes`, `TRejections`\> | ## Returns [`RouterPlugin`](../types/RouterPlugin.md)\<`TRoutes`, `TRejections`\> & [`PluginRouteHooks`](../types/PluginRouteHooks.md)\<`TRoutes`, `TRejections`\> ================================================ FILE: docs/api/functions/createUrl.md ================================================ # Functions: createUrl() ```ts function createUrl(options): ToUrl; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* [`CreateUrlOptions`](../types/CreateUrlOptions.md) | ## Parameters | Parameter | Type | | ------ | ------ | | `options` | `T` | ## Returns [`ToUrl`](../types/ToUrl.md)\<`T`\> ================================================ FILE: docs/api/functions/isUrlWithSchema.md ================================================ # Functions: isUrlWithSchema() ```ts function isUrlWithSchema(url): url is Url & { schema: Record }; ``` **`Internal`** Type guard to assert that a url has a schema. ## Parameters | Parameter | Type | | ------ | ------ | | `url` | `unknown` | ## Returns `url is Url & { schema: Record }` ================================================ FILE: docs/api/functions/isWithComponent.md ================================================ # Functions: isWithComponent() ```ts function isWithComponent(options): options is T & { component: Component }; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* `Record`\<`string`, `unknown`\> | ## Parameters | Parameter | Type | | ------ | ------ | | `options` | `T` | ## Returns `options is T & { component: Component }` ================================================ FILE: docs/api/functions/isWithComponentProps.md ================================================ # Functions: isWithComponentProps() ```ts function isWithComponentProps(options): options is T & { props: PropsGetter }; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* `Record`\<`string`, `unknown`\> | ## Parameters | Parameter | Type | | ------ | ------ | | `options` | `T` | ## Returns `options is T & { props: PropsGetter }` ================================================ FILE: docs/api/functions/isWithComponentPropsRecord.md ================================================ # Functions: isWithComponentPropsRecord() ```ts function isWithComponentPropsRecord(options): options is T & { props: RoutePropsRecord }; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* `Record`\<`string`, `unknown`\> | ## Parameters | Parameter | Type | | ------ | ------ | | `options` | `T` | ## Returns `options is T & { props: RoutePropsRecord }` ================================================ FILE: docs/api/functions/isWithComponents.md ================================================ # Functions: isWithComponents() ```ts function isWithComponents(options): options is T & { components: Record }; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* `Record`\<`string`, `unknown`\> | ## Parameters | Parameter | Type | | ------ | ------ | | `options` | `T` | ## Returns `options is T & { components: Record }` ================================================ FILE: docs/api/functions/isWithParent.md ================================================ # Functions: isWithParent() ```ts function isWithParent(options): options is T & WithParent; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* `Record`\<`string`, `unknown`\> | ## Parameters | Parameter | Type | | ------ | ------ | | `options` | `T` | ## Returns `options is T & WithParent` ================================================ FILE: docs/api/functions/literal.md ================================================ # Functions: literal() ```ts function literal(value): ParamGetSet; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* [`LiteralParam`](../types/LiteralParam.md) | ## Parameters | Parameter | Type | | ------ | ------ | | `value` | `T` | ## Returns [`ParamGetSet`](../types/ParamGetSet.md)\<`T`\> ================================================ FILE: docs/api/functions/tupleOf.md ================================================ # Functions: tupleOf() ```ts function tupleOf(params, options?): ParamGetSet>; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* [`Param`](../types/Param.md)[] | ## Parameters | Parameter | Type | | ------ | ------ | | `params` | `T` | | `options?` | `TupleOfOptions` | ## Returns [`ParamGetSet`](../types/ParamGetSet.md)\<`TupleOf`\<`T`\>\> ================================================ FILE: docs/api/functions/unionOf.md ================================================ # Functions: unionOf() ```ts function unionOf(params): ParamGetSet>; ``` ## Type Parameters | Type Parameter | | ------ | | `T` *extends* [`Param`](../types/Param.md)[] | ## Parameters | Parameter | Type | | ------ | ------ | | `params` | `T` | ## Returns [`ParamGetSet`](../types/ParamGetSet.md)\<`ExtractParamType`\<`T`\[`number`\]\>\> ================================================ FILE: docs/api/functions/withDefault.md ================================================ # Functions: withDefault() ```ts function withDefault(param, defaultValue): ParamWithDefault; ``` ## Type Parameters | Type Parameter | | ------ | | `TParam` *extends* [`Param`](../types/Param.md) | ## Parameters | Parameter | Type | | ------ | ------ | | `param` | `TParam` | | `defaultValue` | `ExtractParamType`\<`TParam`\> | ## Returns `ParamWithDefault`\<`TParam`\> ================================================ FILE: docs/api/functions/withParams.md ================================================ # Functions: withParams() ## Call Signature ```ts function withParams(value, params): UrlPart>; ``` ### Type Parameters | Type Parameter | | ------ | | `TValue` *extends* `string` | | `TParams` *extends* `MakeOptional`\<`WithParamsParamsInput`\<`TValue`\>\> | ### Parameters | Parameter | Type | | ------ | ------ | | `value` | `TValue` | | `params` | `TParams` | ### Returns `UrlPart`\<`WithParamsParamsOutput`\<`TValue`, `TParams`\>\> ## Call Signature ```ts function withParams(): UrlPart<{ }>; ``` ### Returns `UrlPart`\<\{ \}\> ================================================ FILE: docs/api/hooks/onAfterRouteLeave.md ================================================ # Hooks: onAfterRouteLeave ```ts const onAfterRouteLeave: RouterAssets["onAfterRouteLeave"]; ``` Registers a hook that is called after a route has been left. Must be called during setup. This can be used for cleanup actions after the component is no longer active, ensuring proper resource management. ## Param The hook callback function ## Returns A function that removes the added hook. ================================================ FILE: docs/api/hooks/onAfterRouteUpdate.md ================================================ # Hooks: onAfterRouteUpdate ```ts const onAfterRouteUpdate: RouterAssets["onAfterRouteUpdate"]; ``` Registers a hook that is called after a route has been updated. Must be called during setup. This is ideal for responding to updates within the same route, such as parameter changes, without full component reloads. ## Param The hook callback function ## Returns A function that removes the added hook. ================================================ FILE: docs/api/hooks/onBeforeRouteLeave.md ================================================ # Hooks: onBeforeRouteLeave ```ts const onBeforeRouteLeave: RouterAssets["onBeforeRouteLeave"]; ``` Registers a hook that is called before a route is left. Must be called from setup. This is useful for performing actions or cleanups before navigating away from a route component. ## Param The hook callback function ## Returns A function that removes the added hook. ================================================ FILE: docs/api/hooks/onBeforeRouteUpdate.md ================================================ # Hooks: onBeforeRouteUpdate ```ts const onBeforeRouteUpdate: RouterAssets["onBeforeRouteUpdate"]; ``` Registers a hook that is called before a route is updated. Must be called from setup. This is particularly useful for handling changes in route parameters or query while staying within the same component. ## Param The hook callback function ## Returns A function that removes the added hook. ================================================ FILE: docs/api/index.md ================================================ # @kitbag/router ## Compositions - [useLink](compositions/useLink.md) - [useQueryValue](compositions/useQueryValue.md) - [useRejection](compositions/useRejection.md) - [useRoute](compositions/useRoute.md) - [useRouter](compositions/useRouter.md) ## Errors - [DuplicateParamsError](errors/DuplicateParamsError.md) - [MetaPropertyConflict](errors/MetaPropertyConflict.md) - [RouterNotInstalledError](errors/RouterNotInstalledError.md) - [UseRouteInvalidError](errors/UseRouteInvalidError.md) ## Interfaces - [Register](interfaces/Register.md) ## Type Guards - [isRoute](type-guards/isRoute.md) - [isUrlString](type-guards/isUrlString.md) ## Components - [RouterLink](components/RouterLink.md) - [RouterView](components/RouterView.md) ## Functions - [arrayOf](functions/arrayOf.md) - [asUrlString](functions/asUrlString.md) - [combineRoutes](functions/combineRoutes.md) - [createExternalRoute](functions/createExternalRoute.md) - [createParam](functions/createParam.md) - [createRejection](functions/createRejection.md) - [createRoute](functions/createRoute.md) - [createRouter](functions/createRouter.md) - [createRouterAssets](functions/createRouterAssets.md) - [createRouterPlugin](functions/createRouterPlugin.md) - [createUrl](functions/createUrl.md) - [isUrlWithSchema](functions/isUrlWithSchema.md) - [isWithComponent](functions/isWithComponent.md) - [isWithComponentProps](functions/isWithComponentProps.md) - [isWithComponentPropsRecord](functions/isWithComponentPropsRecord.md) - [isWithComponents](functions/isWithComponents.md) - [isWithParent](functions/isWithParent.md) - [literal](functions/literal.md) - [tupleOf](functions/tupleOf.md) - [unionOf](functions/unionOf.md) - [withDefault](functions/withDefault.md) - [withParams](functions/withParams.md) ## Hooks - [onAfterRouteLeave](hooks/onAfterRouteLeave.md) - [onAfterRouteUpdate](hooks/onAfterRouteUpdate.md) - [onBeforeRouteLeave](hooks/onBeforeRouteLeave.md) - [onBeforeRouteUpdate](hooks/onBeforeRouteUpdate.md) ## Types - [AddAfterEnterHook](types/AddAfterEnterHook.md) - [AddAfterLeaveHook](types/AddAfterLeaveHook.md) - [AddAfterUpdateHook](types/AddAfterUpdateHook.md) - [AddBeforeEnterHook](types/AddBeforeEnterHook.md) - [AddBeforeLeaveHook](types/AddBeforeLeaveHook.md) - [AddBeforeUpdateHook](types/AddBeforeUpdateHook.md) - [AddComponentHook](types/AddComponentHook.md) - [AddErrorHook](types/AddErrorHook.md) - [AddGlobalHooks](types/AddGlobalHooks.md) - [AddPluginErrorHook](types/AddPluginErrorHook.md) - [AfterEnterHook](types/AfterEnterHook.md) - [AfterEnterHookContext](types/AfterEnterHookContext.md) - [AfterHookLifecycle](types/AfterHookLifecycle.md) - [AfterHookResponse](types/AfterHookResponse.md) - [AfterHookRunner](types/AfterHookRunner.md) - [AfterLeaveHook](types/AfterLeaveHook.md) - [AfterLeaveHookContext](types/AfterLeaveHookContext.md) - [AfterUpdateHook](types/AfterUpdateHook.md) - [AfterUpdateHookContext](types/AfterUpdateHookContext.md) - [BeforeEnterHook](types/BeforeEnterHook.md) - [BeforeEnterHookContext](types/BeforeEnterHookContext.md) - [BeforeHookLifecycle](types/BeforeHookLifecycle.md) - [BeforeHookResponse](types/BeforeHookResponse.md) - [BeforeHookRunner](types/BeforeHookRunner.md) - [BeforeLeaveHook](types/BeforeLeaveHook.md) - [BeforeLeaveHookContext](types/BeforeLeaveHookContext.md) - [BeforeUpdateHook](types/BeforeUpdateHook.md) - [BeforeUpdateHookContext](types/BeforeUpdateHookContext.md) - [ComponentHook](types/ComponentHook.md) - [ComponentHookRegistration](types/ComponentHookRegistration.md) - [CreatedRouteOptions](types/CreatedRouteOptions.md) - [CreateRouteOptions](types/CreateRouteOptions.md) - [CreateRouteProps](types/CreateRouteProps.md) - [CreateRouterPluginOptions](types/CreateRouterPluginOptions.md) - [CreateUrlOptions](types/CreateUrlOptions.md) - [EmptyRouterPlugin](types/EmptyRouterPlugin.md) - [ErrorHook](types/ErrorHook.md) - [ErrorHookContext](types/ErrorHookContext.md) - [ErrorHookRunner](types/ErrorHookRunner.md) - [ErrorHookRunnerContext](types/ErrorHookRunnerContext.md) - [ExternalRouteHooks](types/ExternalRouteHooks.md) - [GenericRoute](types/GenericRoute.md) - [HookLifecycle](types/HookLifecycle.md) - [HookRemove](types/HookRemove.md) - [HookTiming](types/HookTiming.md) - [InternalRouteHooks](types/InternalRouteHooks.md) - [LiteralParam](types/LiteralParam.md) - [Param](types/Param.md) - [ParamExtras](types/ParamExtras.md) - [ParamGetSet](types/ParamGetSet.md) - [ParamGetter](types/ParamGetter.md) - [ParamSetter](types/ParamSetter.md) - [ParseUrlOptions](types/ParseUrlOptions.md) - [PluginAfterRouteHook](types/PluginAfterRouteHook.md) - [PluginBeforeRouteHook](types/PluginBeforeRouteHook.md) - [PluginErrorHook](types/PluginErrorHook.md) - [PluginErrorHookContext](types/PluginErrorHookContext.md) - [PluginRouteHooks](types/PluginRouteHooks.md) - [PrefetchConfig](types/PrefetchConfig.md) - [PrefetchConfigOptions](types/PrefetchConfigOptions.md) - [PrefetchConfigs](types/PrefetchConfigs.md) - [PrefetchStrategy](types/PrefetchStrategy.md) - [PropsCallbackContext](types/PropsCallbackContext.md) - [PropsCallbackParent](types/PropsCallbackParent.md) - [PropsGetter](types/PropsGetter.md) - [QuerySource](types/QuerySource.md) - [RegisteredRouter](types/RegisteredRouter.md) - [ResolvedRoute](types/ResolvedRoute.md) - [ResolvedRouteUnion](types/ResolvedRouteUnion.md) - [Route](types/Route.md) - [RouteMeta](types/RouteMeta.md) - [Router](types/Router.md) - [RouterAssets](types/RouterAssets.md) - [RouterLinkProps](types/RouterLinkProps.md) - [RouterOptions](types/RouterOptions.md) - [RouterPlugin](types/RouterPlugin.md) - [RouterPush](types/RouterPush.md) - [RouterPushOptions](types/RouterPushOptions.md) - [RouterReject](types/RouterReject.md) - [RouterRejections](types/RouterRejections.md) - [RouterReplace](types/RouterReplace.md) - [RouterReplaceOptions](types/RouterReplaceOptions.md) - [RouterResolve](types/RouterResolve.md) - [RouterResolvedRouteUnion](types/RouterResolvedRouteUnion.md) - [RouterResolveOptions](types/RouterResolveOptions.md) - [RouterRoute](types/RouterRoute.md) - [RouterRouteName](types/RouterRouteName.md) - [RouterRoutes](types/RouterRoutes.md) - [RouterRouteUnion](types/RouterRouteUnion.md) - [RouterViewPropsGetter](types/RouterViewPropsGetter.md) - [Routes](types/Routes.md) - [ToCallback](types/ToCallback.md) - [ToRoute](types/ToRoute.md) - [ToUrl](types/ToUrl.md) - [Url](types/Url.md) - [UrlParamsReading](types/UrlParamsReading.md) - [UrlParamsWriting](types/UrlParamsWriting.md) - [UrlString](types/UrlString.md) - [UseLink](types/UseLink.md) - [UseLinkOptions](types/UseLinkOptions.md) - [WithHost](types/WithHost.md) - [WithoutHost](types/WithoutHost.md) - [WithoutParent](types/WithoutParent.md) - [WithParent](types/WithParent.md) ## Variables - [IS\_URL\_SYMBOL](variables/IS_URL_SYMBOL.md) ================================================ FILE: docs/api/interfaces/Register.md ================================================ # Interfaces: Register Represents the state of currently registered router, and route meta. Used to provide correct type context for components like `RouterLink`, as well as for composables like `useRouter`, `useRoute`, and hooks. ## Example ```ts declare module '@kitbag/router' { interface Register { router: typeof router routeMeta: { public?: boolean } } } ``` ================================================ FILE: docs/api/type-guards/isRoute.md ================================================ # Type Guards: isRoute ```ts const isRoute: RouterAssets["isRoute"]; ``` A guard to verify if a route or unknown value matches a given route name. ## Param The name of the route to check against the current route. ## Returns True if the current route matches the given route name, false otherwise. ================================================ FILE: docs/api/type-guards/isUrlString.md ================================================ # Type Guards: isUrlString() ```ts function isUrlString(value): value is UrlString; ``` A type guard for determining if a value is a valid URL. ## Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `value` | `unknown` | The value to check. | ## Returns `value is UrlString` `true` if the value is a valid URL, otherwise `false`. ================================================ FILE: docs/api/typedoc-sidebar.json ================================================ [ { "text": "Compositions", "collapsed": true, "items": [ { "text": "useLink", "link": "/api/compositions/useLink.md" }, { "text": "useQueryValue", "link": "/api/compositions/useQueryValue.md" }, { "text": "useRejection", "link": "/api/compositions/useRejection.md" }, { "text": "useRoute", "link": "/api/compositions/useRoute.md" }, { "text": "useRouter", "link": "/api/compositions/useRouter.md" } ] }, { "text": "Errors", "collapsed": true, "items": [ { "text": "DuplicateParamsError", "link": "/api/errors/DuplicateParamsError.md" }, { "text": "MetaPropertyConflict", "link": "/api/errors/MetaPropertyConflict.md" }, { "text": "RouterNotInstalledError", "link": "/api/errors/RouterNotInstalledError.md" }, { "text": "UseRouteInvalidError", "link": "/api/errors/UseRouteInvalidError.md" } ] }, { "text": "Interfaces", "collapsed": true, "items": [ { "text": "Register", "link": "/api/interfaces/Register.md" } ] }, { "text": "Type Guards", "collapsed": true, "items": [ { "text": "isRoute", "link": "/api/type-guards/isRoute.md" }, { "text": "isUrlString", "link": "/api/type-guards/isUrlString.md" } ] }, { "text": "Components", "collapsed": true, "items": [ { "text": "RouterLink", "link": "/api/components/RouterLink.md" }, { "text": "RouterView", "link": "/api/components/RouterView.md" } ] }, { "text": "Functions", "collapsed": true, "items": [ { "text": "arrayOf", "link": "/api/functions/arrayOf.md" }, { "text": "asUrlString", "link": "/api/functions/asUrlString.md" }, { "text": "combineRoutes", "link": "/api/functions/combineRoutes.md" }, { "text": "createExternalRoute", "link": "/api/functions/createExternalRoute.md" }, { "text": "createParam", "link": "/api/functions/createParam.md" }, { "text": "createRejection", "link": "/api/functions/createRejection.md" }, { "text": "createRoute", "link": "/api/functions/createRoute.md" }, { "text": "createRouter", "link": "/api/functions/createRouter.md" }, { "text": "createRouterAssets", "link": "/api/functions/createRouterAssets.md" }, { "text": "createRouterPlugin", "link": "/api/functions/createRouterPlugin.md" }, { "text": "createUrl", "link": "/api/functions/createUrl.md" }, { "text": "isUrlWithSchema", "link": "/api/functions/isUrlWithSchema.md" }, { "text": "isWithComponent", "link": "/api/functions/isWithComponent.md" }, { "text": "isWithComponentProps", "link": "/api/functions/isWithComponentProps.md" }, { "text": "isWithComponentPropsRecord", "link": "/api/functions/isWithComponentPropsRecord.md" }, { "text": "isWithComponents", "link": "/api/functions/isWithComponents.md" }, { "text": "isWithParent", "link": "/api/functions/isWithParent.md" }, { "text": "literal", "link": "/api/functions/literal.md" }, { "text": "tupleOf", "link": "/api/functions/tupleOf.md" }, { "text": "unionOf", "link": "/api/functions/unionOf.md" }, { "text": "withDefault", "link": "/api/functions/withDefault.md" }, { "text": "withParams", "link": "/api/functions/withParams.md" } ] }, { "text": "Hooks", "collapsed": true, "items": [ { "text": "onAfterRouteLeave", "link": "/api/hooks/onAfterRouteLeave.md" }, { "text": "onAfterRouteUpdate", "link": "/api/hooks/onAfterRouteUpdate.md" }, { "text": "onBeforeRouteLeave", "link": "/api/hooks/onBeforeRouteLeave.md" }, { "text": "onBeforeRouteUpdate", "link": "/api/hooks/onBeforeRouteUpdate.md" } ] }, { "text": "Types", "collapsed": true, "items": [ { "text": "AddAfterEnterHook", "link": "/api/types/AddAfterEnterHook.md" }, { "text": "AddAfterLeaveHook", "link": "/api/types/AddAfterLeaveHook.md" }, { "text": "AddAfterUpdateHook", "link": "/api/types/AddAfterUpdateHook.md" }, { "text": "AddBeforeEnterHook", "link": "/api/types/AddBeforeEnterHook.md" }, { "text": "AddBeforeLeaveHook", "link": "/api/types/AddBeforeLeaveHook.md" }, { "text": "AddBeforeUpdateHook", "link": "/api/types/AddBeforeUpdateHook.md" }, { "text": "AddComponentHook", "link": "/api/types/AddComponentHook.md" }, { "text": "AddErrorHook", "link": "/api/types/AddErrorHook.md" }, { "text": "AddGlobalHooks", "link": "/api/types/AddGlobalHooks.md" }, { "text": "AddPluginErrorHook", "link": "/api/types/AddPluginErrorHook.md" }, { "text": "AfterEnterHook", "link": "/api/types/AfterEnterHook.md" }, { "text": "AfterEnterHookContext", "link": "/api/types/AfterEnterHookContext.md" }, { "text": "AfterHookLifecycle", "link": "/api/types/AfterHookLifecycle.md" }, { "text": "AfterHookResponse", "link": "/api/types/AfterHookResponse.md" }, { "text": "AfterHookRunner", "link": "/api/types/AfterHookRunner.md" }, { "text": "AfterLeaveHook", "link": "/api/types/AfterLeaveHook.md" }, { "text": "AfterLeaveHookContext", "link": "/api/types/AfterLeaveHookContext.md" }, { "text": "AfterUpdateHook", "link": "/api/types/AfterUpdateHook.md" }, { "text": "AfterUpdateHookContext", "link": "/api/types/AfterUpdateHookContext.md" }, { "text": "BeforeEnterHook", "link": "/api/types/BeforeEnterHook.md" }, { "text": "BeforeEnterHookContext", "link": "/api/types/BeforeEnterHookContext.md" }, { "text": "BeforeHookLifecycle", "link": "/api/types/BeforeHookLifecycle.md" }, { "text": "BeforeHookResponse", "link": "/api/types/BeforeHookResponse.md" }, { "text": "BeforeHookRunner", "link": "/api/types/BeforeHookRunner.md" }, { "text": "BeforeLeaveHook", "link": "/api/types/BeforeLeaveHook.md" }, { "text": "BeforeLeaveHookContext", "link": "/api/types/BeforeLeaveHookContext.md" }, { "text": "BeforeUpdateHook", "link": "/api/types/BeforeUpdateHook.md" }, { "text": "BeforeUpdateHookContext", "link": "/api/types/BeforeUpdateHookContext.md" }, { "text": "ComponentHook", "link": "/api/types/ComponentHook.md" }, { "text": "ComponentHookRegistration", "link": "/api/types/ComponentHookRegistration.md" }, { "text": "CreatedRouteOptions", "link": "/api/types/CreatedRouteOptions.md" }, { "text": "CreateRouteOptions", "link": "/api/types/CreateRouteOptions.md" }, { "text": "CreateRouteProps", "link": "/api/types/CreateRouteProps.md" }, { "text": "CreateRouterPluginOptions", "link": "/api/types/CreateRouterPluginOptions.md" }, { "text": "CreateUrlOptions", "link": "/api/types/CreateUrlOptions.md" }, { "text": "EmptyRouterPlugin", "link": "/api/types/EmptyRouterPlugin.md" }, { "text": "ErrorHook", "link": "/api/types/ErrorHook.md" }, { "text": "ErrorHookContext", "link": "/api/types/ErrorHookContext.md" }, { "text": "ErrorHookRunner", "link": "/api/types/ErrorHookRunner.md" }, { "text": "ErrorHookRunnerContext", "link": "/api/types/ErrorHookRunnerContext.md" }, { "text": "ExternalRouteHooks", "link": "/api/types/ExternalRouteHooks.md" }, { "text": "GenericRoute", "link": "/api/types/GenericRoute.md" }, { "text": "HookLifecycle", "link": "/api/types/HookLifecycle.md" }, { "text": "HookRemove", "link": "/api/types/HookRemove.md" }, { "text": "HookTiming", "link": "/api/types/HookTiming.md" }, { "text": "InternalRouteHooks", "link": "/api/types/InternalRouteHooks.md" }, { "text": "LiteralParam", "link": "/api/types/LiteralParam.md" }, { "text": "Param", "link": "/api/types/Param.md" }, { "text": "ParamExtras", "link": "/api/types/ParamExtras.md" }, { "text": "ParamGetSet", "link": "/api/types/ParamGetSet.md" }, { "text": "ParamGetter", "link": "/api/types/ParamGetter.md" }, { "text": "ParamSetter", "link": "/api/types/ParamSetter.md" }, { "text": "ParseUrlOptions", "link": "/api/types/ParseUrlOptions.md" }, { "text": "PluginAfterRouteHook", "link": "/api/types/PluginAfterRouteHook.md" }, { "text": "PluginBeforeRouteHook", "link": "/api/types/PluginBeforeRouteHook.md" }, { "text": "PluginErrorHook", "link": "/api/types/PluginErrorHook.md" }, { "text": "PluginErrorHookContext", "link": "/api/types/PluginErrorHookContext.md" }, { "text": "PluginRouteHooks", "link": "/api/types/PluginRouteHooks.md" }, { "text": "PrefetchConfig", "link": "/api/types/PrefetchConfig.md" }, { "text": "PrefetchConfigOptions", "link": "/api/types/PrefetchConfigOptions.md" }, { "text": "PrefetchConfigs", "link": "/api/types/PrefetchConfigs.md" }, { "text": "PrefetchStrategy", "link": "/api/types/PrefetchStrategy.md" }, { "text": "PropsCallbackContext", "link": "/api/types/PropsCallbackContext.md" }, { "text": "PropsCallbackParent", "link": "/api/types/PropsCallbackParent.md" }, { "text": "PropsGetter", "link": "/api/types/PropsGetter.md" }, { "text": "QuerySource", "link": "/api/types/QuerySource.md" }, { "text": "RegisteredRouter", "link": "/api/types/RegisteredRouter.md" }, { "text": "ResolvedRoute", "link": "/api/types/ResolvedRoute.md" }, { "text": "ResolvedRouteUnion", "link": "/api/types/ResolvedRouteUnion.md" }, { "text": "Route", "link": "/api/types/Route.md" }, { "text": "RouteMeta", "link": "/api/types/RouteMeta.md" }, { "text": "Router", "link": "/api/types/Router.md" }, { "text": "RouterAssets", "link": "/api/types/RouterAssets.md" }, { "text": "RouterLinkProps", "link": "/api/types/RouterLinkProps.md" }, { "text": "RouterOptions", "link": "/api/types/RouterOptions.md" }, { "text": "RouterPlugin", "link": "/api/types/RouterPlugin.md" }, { "text": "RouterPush", "link": "/api/types/RouterPush.md" }, { "text": "RouterPushOptions", "link": "/api/types/RouterPushOptions.md" }, { "text": "RouterReject", "link": "/api/types/RouterReject.md" }, { "text": "RouterRejections", "link": "/api/types/RouterRejections.md" }, { "text": "RouterReplace", "link": "/api/types/RouterReplace.md" }, { "text": "RouterReplaceOptions", "link": "/api/types/RouterReplaceOptions.md" }, { "text": "RouterResolve", "link": "/api/types/RouterResolve.md" }, { "text": "RouterResolvedRouteUnion", "link": "/api/types/RouterResolvedRouteUnion.md" }, { "text": "RouterResolveOptions", "link": "/api/types/RouterResolveOptions.md" }, { "text": "RouterRoute", "link": "/api/types/RouterRoute.md" }, { "text": "RouterRouteName", "link": "/api/types/RouterRouteName.md" }, { "text": "RouterRoutes", "link": "/api/types/RouterRoutes.md" }, { "text": "RouterRouteUnion", "link": "/api/types/RouterRouteUnion.md" }, { "text": "RouterViewPropsGetter", "link": "/api/types/RouterViewPropsGetter.md" }, { "text": "Routes", "link": "/api/types/Routes.md" }, { "text": "ToCallback", "link": "/api/types/ToCallback.md" }, { "text": "ToRoute", "link": "/api/types/ToRoute.md" }, { "text": "ToUrl", "link": "/api/types/ToUrl.md" }, { "text": "Url", "link": "/api/types/Url.md" }, { "text": "UrlParamsReading", "link": "/api/types/UrlParamsReading.md" }, { "text": "UrlParamsWriting", "link": "/api/types/UrlParamsWriting.md" }, { "text": "UrlString", "link": "/api/types/UrlString.md" }, { "text": "UseLink", "link": "/api/types/UseLink.md" }, { "text": "UseLinkOptions", "link": "/api/types/UseLinkOptions.md" }, { "text": "WithHost", "link": "/api/types/WithHost.md" }, { "text": "WithoutHost", "link": "/api/types/WithoutHost.md" }, { "text": "WithoutParent", "link": "/api/types/WithoutParent.md" }, { "text": "WithParent", "link": "/api/types/WithParent.md" } ] }, { "text": "Variables", "collapsed": true, "items": [ { "text": "IS_URL_SYMBOL", "link": "/api/variables/IS_URL_SYMBOL.md" } ] } ] ================================================ FILE: docs/api/types/AddAfterEnterHook.md ================================================ # Types: AddAfterEnterHook()\ ```ts type AddAfterEnterHook = (hook) => HookRemove; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `hook` | [`AfterEnterHook`](AfterEnterHook.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AddAfterLeaveHook.md ================================================ # Types: AddAfterLeaveHook()\ ```ts type AddAfterLeaveHook = (hook) => HookRemove; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `hook` | [`AfterLeaveHook`](AfterLeaveHook.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AddAfterUpdateHook.md ================================================ # Types: AddAfterUpdateHook()\ ```ts type AddAfterUpdateHook = (hook) => HookRemove; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `hook` | [`AfterUpdateHook`](AfterUpdateHook.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AddBeforeEnterHook.md ================================================ # Types: AddBeforeEnterHook()\ ```ts type AddBeforeEnterHook = (hook) => HookRemove; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `hook` | [`BeforeEnterHook`](BeforeEnterHook.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AddBeforeLeaveHook.md ================================================ # Types: AddBeforeLeaveHook()\ ```ts type AddBeforeLeaveHook = (hook) => HookRemove; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `hook` | [`BeforeLeaveHook`](BeforeLeaveHook.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AddBeforeUpdateHook.md ================================================ # Types: AddBeforeUpdateHook()\ ```ts type AddBeforeUpdateHook = (hook) => HookRemove; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `hook` | [`BeforeUpdateHook`](BeforeUpdateHook.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AddComponentHook.md ================================================ # Types: AddComponentHook() ```ts type AddComponentHook = (registration) => HookRemove; ``` Function to add a component route hook with depth-based condition checking. ## Parameters | Parameter | Type | | ------ | ------ | | `registration` | [`ComponentHookRegistration`](ComponentHookRegistration.md) | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AddErrorHook.md ================================================ # Types: AddErrorHook()\ ```ts type AddErrorHook = (hook) => HookRemove; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoute` *extends* [`Route`](Route.md) | [`Route`](Route.md) | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Parameters | Parameter | Type | | ------ | ------ | | `hook` | [`ErrorHook`](ErrorHook.md)\<`TRoute`, `TRoutes`, `TRejections`\> | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AddGlobalHooks.md ================================================ # Types: AddGlobalHooks() ```ts type AddGlobalHooks = (hooks) => void; ``` ## Parameters | Parameter | Type | | ------ | ------ | | `hooks` | `Hooks` | ## Returns `void` ================================================ FILE: docs/api/types/AddPluginErrorHook.md ================================================ # Types: AddPluginErrorHook()\ ```ts type AddPluginErrorHook = (hook) => HookRemove; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Parameters | Parameter | Type | | ------ | ------ | | `hook` | [`PluginErrorHook`](PluginErrorHook.md)\<`TRoutes`, `TRejections`\> | ## Returns [`HookRemove`](HookRemove.md) ================================================ FILE: docs/api/types/AfterEnterHook.md ================================================ # Types: AfterEnterHook()\ ```ts type AfterEnterHook = (to, context) => MaybePromise; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `to` | [`ResolvedRouteUnion`](ResolvedRouteUnion.md)\<`TRouteTo`\> | | `context` | [`AfterEnterHookContext`](AfterEnterHookContext.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns `MaybePromise`\<`void`\> ================================================ FILE: docs/api/types/AfterEnterHookContext.md ================================================ # Types: AfterEnterHookContext\ ```ts type AfterEnterHookContext = AfterHookContext & object; ``` ## Type Declaration ### from ```ts from: ResolvedRouteUnion | null; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ================================================ FILE: docs/api/types/AfterHookLifecycle.md ================================================ # Types: AfterHookLifecycle ```ts type AfterHookLifecycle = "onAfterRouteEnter" | "onAfterRouteUpdate" | "onAfterRouteLeave"; ``` Enumerates the lifecycle events for after route hooks. ================================================ FILE: docs/api/types/AfterHookResponse.md ================================================ # Types: AfterHookResponse ```ts type AfterHookResponse = CallbackContextSuccess | CallbackContextPush | CallbackContextReject; ``` ================================================ FILE: docs/api/types/AfterHookRunner.md ================================================ # Types: AfterHookRunner() ```ts type AfterHookRunner = (context) => Promise; ``` ## Type Parameters | Type Parameter | | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | ## Parameters | Parameter | Type | | ------ | ------ | | `context` | \{ `from`: \| [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> \| `null`; `to`: [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\>; \} | | `context.from` | \| [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> \| `null` | | `context.to` | [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> | ## Returns `Promise`\<[`AfterHookResponse`](AfterHookResponse.md)\> ================================================ FILE: docs/api/types/AfterLeaveHook.md ================================================ # Types: AfterLeaveHook()\ ```ts type AfterLeaveHook = (to, context) => MaybePromise; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `to` | [`ResolvedRouteUnion`](ResolvedRouteUnion.md)\<`TRouteTo`\> | | `context` | [`AfterLeaveHookContext`](AfterLeaveHookContext.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns `MaybePromise`\<`void`\> ================================================ FILE: docs/api/types/AfterLeaveHookContext.md ================================================ # Types: AfterLeaveHookContext\ ```ts type AfterLeaveHookContext = AfterHookContext & object; ``` ## Type Declaration ### from ```ts from: ResolvedRouteUnion; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ================================================ FILE: docs/api/types/AfterUpdateHook.md ================================================ # Types: AfterUpdateHook()\ ```ts type AfterUpdateHook = (to, context) => MaybePromise; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `to` | [`ResolvedRouteUnion`](ResolvedRouteUnion.md)\<`TRouteTo`\> | | `context` | [`AfterUpdateHookContext`](AfterUpdateHookContext.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns `MaybePromise`\<`void`\> ================================================ FILE: docs/api/types/AfterUpdateHookContext.md ================================================ # Types: AfterUpdateHookContext\ ```ts type AfterUpdateHookContext = AfterHookContext & object; ``` ## Type Declaration ### from ```ts from: ResolvedRouteUnion | null; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ================================================ FILE: docs/api/types/BeforeEnterHook.md ================================================ # Types: BeforeEnterHook()\ ```ts type BeforeEnterHook = (to, context) => MaybePromise; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `to` | [`ResolvedRouteUnion`](ResolvedRouteUnion.md)\<`TRouteTo`\> | | `context` | [`BeforeEnterHookContext`](BeforeEnterHookContext.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns `MaybePromise`\<`void`\> ================================================ FILE: docs/api/types/BeforeEnterHookContext.md ================================================ # Types: BeforeEnterHookContext\ ```ts type BeforeEnterHookContext = BeforeHookContext & object; ``` ## Type Declaration ### from ```ts from: ResolvedRouteUnion | null; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ================================================ FILE: docs/api/types/BeforeHookLifecycle.md ================================================ # Types: BeforeHookLifecycle ```ts type BeforeHookLifecycle = "onBeforeRouteEnter" | "onBeforeRouteUpdate" | "onBeforeRouteLeave"; ``` Enumerates the lifecycle events for before route hooks. ================================================ FILE: docs/api/types/BeforeHookResponse.md ================================================ # Types: BeforeHookResponse ```ts type BeforeHookResponse = | CallbackContextSuccess | CallbackContextPush | CallbackContextReject | CallbackContextAbort; ``` ================================================ FILE: docs/api/types/BeforeHookRunner.md ================================================ # Types: BeforeHookRunner() ```ts type BeforeHookRunner = (context) => Promise; ``` ## Type Parameters | Type Parameter | | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | ## Parameters | Parameter | Type | | ------ | ------ | | `context` | \{ `from`: \| [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> \| `null`; `to`: [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\>; \} | | `context.from` | \| [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> \| `null` | | `context.to` | [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> | ## Returns `Promise`\<[`BeforeHookResponse`](BeforeHookResponse.md)\> ================================================ FILE: docs/api/types/BeforeLeaveHook.md ================================================ # Types: BeforeLeaveHook()\ ```ts type BeforeLeaveHook = (to, context) => MaybePromise; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `to` | [`ResolvedRouteUnion`](ResolvedRouteUnion.md)\<`TRouteTo`\> | | `context` | [`BeforeLeaveHookContext`](BeforeLeaveHookContext.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns `MaybePromise`\<`void`\> ================================================ FILE: docs/api/types/BeforeLeaveHookContext.md ================================================ # Types: BeforeLeaveHookContext\ ```ts type BeforeLeaveHookContext = BeforeHookContext & object; ``` ## Type Declaration ### from ```ts from: ResolvedRouteUnion; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ================================================ FILE: docs/api/types/BeforeUpdateHook.md ================================================ # Types: BeforeUpdateHook()\ ```ts type BeforeUpdateHook = (to, context) => MaybePromise; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ## Parameters | Parameter | Type | | ------ | ------ | | `to` | [`ResolvedRouteUnion`](ResolvedRouteUnion.md)\<`TRouteTo`\> | | `context` | [`BeforeUpdateHookContext`](BeforeUpdateHookContext.md)\<`TRoutes`, `TRejections`, `TRouteTo`, `TRouteFrom`\> | ## Returns `MaybePromise`\<`void`\> ================================================ FILE: docs/api/types/BeforeUpdateHookContext.md ================================================ # Types: BeforeUpdateHookContext\ ```ts type BeforeUpdateHookContext = BeforeHookContext & object; ``` ## Type Declaration ### from ```ts from: ResolvedRouteUnion | null; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | | `TRouteTo` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | | `TRouteFrom` *extends* [`Route`](Route.md) | `TRoutes`\[`number`\] | ================================================ FILE: docs/api/types/ComponentHook.md ================================================ # Types: ComponentHook ```ts type ComponentHook = | BeforeEnterHook | BeforeUpdateHook | BeforeLeaveHook | AfterEnterHook | AfterUpdateHook | AfterLeaveHook; ``` Union type for all component route hooks. ================================================ FILE: docs/api/types/ComponentHookRegistration.md ================================================ # Types: ComponentHookRegistration ```ts type ComponentHookRegistration = object; ``` Registration object for adding a component route hook. ## Properties | Property | Type | | ------ | ------ | | `depth` | `number` | | `hook` | [`ComponentHook`](ComponentHook.md) | | `lifecycle` | [`HookLifecycle`](HookLifecycle.md) | ================================================ FILE: docs/api/types/CreateRouteOptions.md ================================================ # Types: CreateRouteOptions\ ```ts type CreateRouteOptions = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TName` *extends* `string` \| `undefined` | `string` \| `undefined` | | `TMeta` *extends* [`RouteMeta`](RouteMeta.md) | [`RouteMeta`](RouteMeta.md) | ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `component?` | `Component` | An optional component to render when this route is matched. **Default** `RouterView` | | `components?` | `Record`\<`string`, `Component`\> | An object of named components to render using named views | | `context?` | `RouteContext`[] | Related routes and rejections for the route. The context is exposed to the hooks and props callback functions for this route. | | `hash?` | `string` \| `UrlPart` | Hash part of URL. | | `meta?` | `TMeta` | Represents additional metadata associated with a route, customizable via declaration merging. | | `name?` | `TName` | Name for route, used to create route keys and in navigation. | | `parent?` | [`Route`](Route.md) | An optional parent route to nest this route under. | | `path?` | `string` \| `UrlPart` | Path part of URL. | | `prefetch?` | [`PrefetchConfig`](PrefetchConfig.md) | Determines what assets are prefetched when router-link is rendered for this route. Overrides router level prefetch. | | `query?` | `string` \| `UrlQueryPart` | Query (aka search) part of URL. | | `state?` | `Record`\<`string`, [`Param`](Param.md)\> | Type params for additional data intended to be stored in history state, all keys will be optional unless a default is provided. | ================================================ FILE: docs/api/types/CreateRouteProps.md ================================================ # Types: CreateRouteProps\ ```ts type CreateRouteProps = TOptions["component"] extends Component ? PropsGetter : TOptions["components"] extends Record ? RoutePropsRecord : RouterViewPropsGetter; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TOptions` *extends* [`CreateRouteOptions`](CreateRouteOptions.md) | [`CreateRouteOptions`](CreateRouteOptions.md) | ================================================ FILE: docs/api/types/CreateRouterPluginOptions.md ================================================ # Types: CreateRouterPluginOptions\ ```ts type CreateRouterPluginOptions = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Properties | Property | Type | | ------ | ------ | | `rejections?` | `TRejections` | | `routes?` | `TRoutes` | ================================================ FILE: docs/api/types/CreateUrlOptions.md ================================================ # Types: CreateUrlOptions ```ts type CreateUrlOptions = object; ``` ## Properties | Property | Type | | ------ | ------ | | `hash?` | `string` \| `UrlPart` | | `host?` | `string` \| `UrlPart` | | `path?` | `string` \| `UrlPart` | | `query?` | `string` \| `UrlQueryPart` | ================================================ FILE: docs/api/types/CreatedRouteOptions.md ================================================ # Types: CreatedRouteOptions ```ts type CreatedRouteOptions = Omit & object; ``` The Route properties originally provided to `createRoute`. The only change is normalizing meta to always default to an empty object. ## Type Declaration ### id ```ts id: string; ``` ### props? ```ts optional props: unknown; ``` ================================================ FILE: docs/api/types/EmptyRouterPlugin.md ================================================ # Types: EmptyRouterPlugin ```ts type EmptyRouterPlugin = RouterPlugin<[], []>; ``` ================================================ FILE: docs/api/types/ErrorHook.md ================================================ # Types: ErrorHook()\ ```ts type ErrorHook = (error, context) => void; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoute` *extends* [`Route`](Route.md) | [`Route`](Route.md) | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | | `context` | [`ErrorHookContext`](ErrorHookContext.md)\<`TRoute`, `TRoutes`, `TRejections`\> | ## Returns `void` ================================================ FILE: docs/api/types/ErrorHookContext.md ================================================ # Types: ErrorHookContext\ ```ts type ErrorHookContext = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoute` *extends* [`Route`](Route.md) | [`Route`](Route.md) | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Properties | Property | Type | | ------ | ------ | | `from` | \| [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> \| `null` | | `push` | [`RouterPush`](RouterPush.md)\<`TRoutes`\> | | `reject` | [`RouterReject`](RouterReject.md)\<`TRejections`\> | | `replace` | [`RouterReplace`](RouterReplace.md)\<`TRoutes`\> | | `source` | `"props"` \| `"hook"` \| `"component"` | | `to` | [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> | | `update` | `RouteUpdate`\<[`ResolvedRoute`](ResolvedRoute.md)\<`TRoute`\>\> | ================================================ FILE: docs/api/types/ErrorHookRunner.md ================================================ # Types: ErrorHookRunner() ```ts type ErrorHookRunner = (error, context) => void; ``` ## Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | | `context` | [`ErrorHookRunnerContext`](ErrorHookRunnerContext.md) | ## Returns `void` ================================================ FILE: docs/api/types/ErrorHookRunnerContext.md ================================================ # Types: ErrorHookRunnerContext\ ```ts type ErrorHookRunnerContext = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | ## Properties | Property | Type | | ------ | ------ | | `from` | \| [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> \| `null` | | `source` | `"props"` \| `"hook"` | | `to` | [`RouterResolvedRouteUnion`](RouterResolvedRouteUnion.md)\<`TRoutes`\> | ================================================ FILE: docs/api/types/ExternalRouteHooks.md ================================================ # Types: ExternalRouteHooks\ ```ts type ExternalRouteHooks = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoute` *extends* [`Route`](Route.md) | [`Route`](Route.md) | | `TContext` *extends* `RouteContext`[] \| `undefined` | `undefined` | ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `onBeforeRouteEnter` | [`AddBeforeEnterHook`](AddBeforeEnterHook.md)\<\[`TRoute`\] \| `RouteContextToRoute`\<`TContext`\>, `RouteContextToRejection`\<`TContext`\>, `TRoute`, [`Route`](Route.md)\> | Registers a route hook to be called before the route is entered. | ================================================ FILE: docs/api/types/GenericRoute.md ================================================ # Types: GenericRoute ```ts type GenericRoute = Url & object; ``` ## Type Declaration ### depth ```ts depth: number; ``` ### id ```ts id: string; ``` ### matched ```ts matched: CreatedRouteOptions; ``` ### matches ```ts matches: CreatedRouteOptions[]; ``` ### meta ```ts meta: RouteMeta; ``` ### name ```ts name: string; ``` ### prefetch? ```ts optional prefetch: PrefetchConfig; ``` ### state ```ts state: Record; ``` ================================================ FILE: docs/api/types/HookLifecycle.md ================================================ # Types: HookLifecycle ```ts type HookLifecycle = | BeforeHookLifecycle | AfterHookLifecycle; ``` Union type for all route hook lifecycle events. ================================================ FILE: docs/api/types/HookRemove.md ================================================ # Types: HookRemove() ```ts type HookRemove = () => void; ``` A function to remove a previously added route hook. ## Returns `void` ================================================ FILE: docs/api/types/HookTiming.md ================================================ # Types: HookTiming ```ts type HookTiming = "global" | "component"; ``` ================================================ FILE: docs/api/types/InternalRouteHooks.md ================================================ # Types: InternalRouteHooks\ ```ts type InternalRouteHooks = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoute` *extends* [`Route`](Route.md) | [`Route`](Route.md) | | `TContext` *extends* `RouteContext`[] | \[\] | ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `onAfterRouteEnter` | [`AddAfterEnterHook`](AddAfterEnterHook.md)\<\[`TRoute`\] \| `RouteContextToRoute`\<`TContext`\>, `RouteContextToRejection`\<`TContext`\>, `TRoute`, [`Route`](Route.md)\> | Registers a route hook to be called after the route is entered. | | `onAfterRouteLeave` | [`AddAfterLeaveHook`](AddAfterLeaveHook.md)\<\[`TRoute`\] \| `RouteContextToRoute`\<`TContext`\>, `RouteContextToRejection`\<`TContext`\>, [`Route`](Route.md), `TRoute`\> | Registers a route hook to be called after the route is left. | | `onAfterRouteUpdate` | [`AddAfterUpdateHook`](AddAfterUpdateHook.md)\<\[`TRoute`\] \| `RouteContextToRoute`\<`TContext`\>, `RouteContextToRejection`\<`TContext`\>, `TRoute`, [`Route`](Route.md)\> | Registers a route hook to be called after the route is updated. | | `onBeforeRouteEnter` | [`AddBeforeEnterHook`](AddBeforeEnterHook.md)\<\[`TRoute`\] \| `RouteContextToRoute`\<`TContext`\>, `RouteContextToRejection`\<`TContext`\>, `TRoute`, [`Route`](Route.md)\> | Registers a route hook to be called before the route is entered. | | `onBeforeRouteLeave` | [`AddBeforeLeaveHook`](AddBeforeLeaveHook.md)\<\[`TRoute`\] \| `RouteContextToRoute`\<`TContext`\>, `RouteContextToRejection`\<`TContext`\>, [`Route`](Route.md), `TRoute`\> | Registers a route hook to be called before the route is left. | | `onBeforeRouteUpdate` | [`AddBeforeUpdateHook`](AddBeforeUpdateHook.md)\<\[`TRoute`\] \| `RouteContextToRoute`\<`TContext`\>, `RouteContextToRejection`\<`TContext`\>, `TRoute`, [`Route`](Route.md)\> | Registers a route hook to be called before the route is updated. | ================================================ FILE: docs/api/types/LiteralParam.md ================================================ # Types: LiteralParam ```ts type LiteralParam = string | number | boolean; ``` ================================================ FILE: docs/api/types/Param.md ================================================ # Types: Param ```ts type Param = | ParamGetter | ParamGetSet | RegExp | BooleanConstructor | NumberConstructor | StringConstructor | DateConstructor | JSON | ZodSchemaLike | ValibotSchemaLike | LiteralParam; ``` ================================================ FILE: docs/api/types/ParamExtras.md ================================================ # Types: ParamExtras ```ts type ParamExtras = object; ``` ## Properties | Property | Type | | ------ | ------ | | `invalid` | (`message?`) => `never` | ================================================ FILE: docs/api/types/ParamGetSet.md ================================================ # Types: ParamGetSet\ ```ts type ParamGetSet = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `T` | `any` | ## Properties | Property | Type | | ------ | ------ | | `defaultValue?` | `T` | | `get` | [`ParamGetter`](ParamGetter.md)\<`T`\> | | `set` | [`ParamSetter`](ParamSetter.md)\<`T`\> | ================================================ FILE: docs/api/types/ParamGetter.md ================================================ # Types: ParamGetter()\ ```ts type ParamGetter = (value, extras) => T; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `T` | `any` | ## Parameters | Parameter | Type | | ------ | ------ | | `value` | `string` | | `extras` | [`ParamExtras`](ParamExtras.md) | ## Returns `T` ================================================ FILE: docs/api/types/ParamSetter.md ================================================ # Types: ParamSetter()\ ```ts type ParamSetter = (value, extras) => string; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `T` | `any` | ## Parameters | Parameter | Type | | ------ | ------ | | `value` | `T` | | `extras` | [`ParamExtras`](ParamExtras.md) | ## Returns `string` ================================================ FILE: docs/api/types/ParseUrlOptions.md ================================================ # Types: ParseUrlOptions ```ts type ParseUrlOptions = object; ``` ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `removeTrailingSlashes?` | `boolean` | Whether to remove trailing slashes from the path. When true, trailing slashes will be removed from the path. **Default** `true` | ================================================ FILE: docs/api/types/PluginAfterRouteHook.md ================================================ # Types: PluginAfterRouteHook()\ ```ts type PluginAfterRouteHook = (to, context) => MaybePromise; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Parameters | Parameter | Type | | ------ | ------ | | `to` | [`ResolvedRoute`](ResolvedRoute.md) | | `context` | `PluginAfterRouteHookContext`\<`TRoutes`, `TRejections`\> | ## Returns `MaybePromise`\<`void`\> ================================================ FILE: docs/api/types/PluginBeforeRouteHook.md ================================================ # Types: PluginBeforeRouteHook()\ ```ts type PluginBeforeRouteHook = (to, context) => MaybePromise; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Parameters | Parameter | Type | | ------ | ------ | | `to` | [`ResolvedRoute`](ResolvedRoute.md) | | `context` | `PluginBeforeRouteHookContext`\<`TRoutes`, `TRejections`\> | ## Returns `MaybePromise`\<`void`\> ================================================ FILE: docs/api/types/PluginErrorHook.md ================================================ # Types: PluginErrorHook()\ ```ts type PluginErrorHook = (error, context) => void; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Parameters | Parameter | Type | | ------ | ------ | | `error` | `unknown` | | `context` | [`PluginErrorHookContext`](PluginErrorHookContext.md)\<`TRoutes`, `TRejections`\> | ## Returns `void` ================================================ FILE: docs/api/types/PluginErrorHookContext.md ================================================ # Types: PluginErrorHookContext\ ```ts type PluginErrorHookContext = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Properties | Property | Type | | ------ | ------ | | `from` | [`ResolvedRoute`](ResolvedRoute.md) \| `null` | | `push` | [`RouterPush`](RouterPush.md)\<`TRoutes`\> | | `reject` | [`RouterReject`](RouterReject.md)\<`TRejections`\> | | `replace` | [`RouterReplace`](RouterReplace.md)\<`TRoutes`\> | | `source` | `"props"` \| `"hook"` \| `"component"` | | `to` | [`ResolvedRoute`](ResolvedRoute.md) | ================================================ FILE: docs/api/types/PluginRouteHooks.md ================================================ # Types: PluginRouteHooks\ ```ts type PluginRouteHooks = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `onAfterRouteEnter` | `AddPluginAfterRouteHook`\<`TRoutes`, `TRejections`\> | Registers a global hook to be called after a route is entered. | | `onAfterRouteLeave` | `AddPluginAfterRouteHook`\<`TRoutes`, `TRejections`\> | Registers a global hook to be called after a route is left. | | `onAfterRouteUpdate` | `AddPluginAfterRouteHook`\<`TRoutes`, `TRejections`\> | Registers a global hook to be called after a route is updated. | | `onBeforeRouteEnter` | `AddPluginBeforeRouteHook`\<`TRoutes`, `TRejections`\> | Registers a global hook to be called before a route is entered. | | `onBeforeRouteLeave` | `AddPluginBeforeRouteHook`\<`TRoutes`, `TRejections`\> | Registers a global hook to be called before a route is left. | | `onBeforeRouteUpdate` | `AddPluginBeforeRouteHook`\<`TRoutes`, `TRejections`\> | Registers a global hook to be called before a route is updated. | | `onError` | [`AddPluginErrorHook`](AddPluginErrorHook.md)\<`TRoutes`, `TRejections`\> | Registers a global hook to be called when an error occurs. | ================================================ FILE: docs/api/types/PrefetchConfig.md ================================================ # Types: PrefetchConfig ```ts type PrefetchConfig = | boolean | PrefetchStrategy | PrefetchConfigOptions; ``` Determines what assets are prefetched. A boolean enables or disables all prefetching. ================================================ FILE: docs/api/types/PrefetchConfigOptions.md ================================================ # Types: PrefetchConfigOptions ```ts type PrefetchConfigOptions = object; ``` ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `components?` | `boolean` \| [`PrefetchStrategy`](PrefetchStrategy.md) | When true any component that is wrapped in vue's defineAsyncComponent will be prefetched **Default** `'eager'` | | `props?` | `boolean` \| [`PrefetchStrategy`](PrefetchStrategy.md) | When true any props for routes will be prefetched **Default** `false` | ================================================ FILE: docs/api/types/PrefetchConfigs.md ================================================ # Types: PrefetchConfigs ```ts type PrefetchConfigs = object; ``` ## Properties | Property | Type | | ------ | ------ | | `linkPrefetch?` | [`PrefetchConfig`](PrefetchConfig.md) | | `routePrefetch?` | [`PrefetchConfig`](PrefetchConfig.md) | | `routerPrefetch?` | [`PrefetchConfig`](PrefetchConfig.md) | ================================================ FILE: docs/api/types/PrefetchStrategy.md ================================================ # Types: PrefetchStrategy ```ts type PrefetchStrategy = "eager" | "lazy" | "intent"; ``` Determines when assets are prefetched. eager: Fetched immediately lazy: Fetched when visible ================================================ FILE: docs/api/types/PropsCallbackContext.md ================================================ # Types: PropsCallbackContext\ ```ts type PropsCallbackContext = object; ``` Context provided to props callback functions ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoute` *extends* [`Route`](Route.md) | [`Route`](Route.md) | | `TOptions` *extends* [`CreateRouteOptions`](CreateRouteOptions.md) | [`CreateRouteOptions`](CreateRouteOptions.md) | ## Properties | Property | Type | | ------ | ------ | | `parent` | [`PropsCallbackParent`](PropsCallbackParent.md)\<`TOptions`\[`"parent"`\]\> | | `push` | [`RouterPush`](RouterPush.md)\<\[`TRoute`\] \| `ExtractRouteContextRoutes`\<`TOptions`\>\> | | `reject` | [`RouterReject`](RouterReject.md)\<`ExtractRouteContextRejections`\<`TOptions`\>\> | | `replace` | [`RouterReplace`](RouterReplace.md)\<\[`TRoute`\] \| `ExtractRouteContextRoutes`\<`TOptions`\>\> | | `update` | `RouteUpdate`\<[`ResolvedRoute`](ResolvedRoute.md)\<`TRoute`\>\> | ================================================ FILE: docs/api/types/PropsCallbackParent.md ================================================ # Types: PropsCallbackParent\ ```ts type PropsCallbackParent = Route | undefined extends TParent ? | undefined | { name: string; props: unknown; } : TParent extends Route ? object : undefined; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TParent` *extends* [`Route`](Route.md) \| `undefined` | [`Route`](Route.md) \| `undefined` | ================================================ FILE: docs/api/types/PropsGetter.md ================================================ # Types: PropsGetter()\ ```ts type PropsGetter = (route, context) => MaybePromise>; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TOptions` *extends* [`CreateRouteOptions`](CreateRouteOptions.md) | [`CreateRouteOptions`](CreateRouteOptions.md) | | `TComponent` *extends* `Component` | `Component` | ## Parameters | Parameter | Type | | ------ | ------ | | `route` | [`ResolvedRoute`](ResolvedRoute.md)\<[`ToRoute`](ToRoute.md)\<`TOptions`\>\> | | `context` | [`PropsCallbackContext`](PropsCallbackContext.md)\<[`ToRoute`](ToRoute.md)\<`TOptions`\>, `TOptions`\> | ## Returns `MaybePromise`\<`ComponentProps`\<`TComponent`\>\> ================================================ FILE: docs/api/types/QuerySource.md ================================================ # Types: QuerySource ```ts type QuerySource = ConstructorParameters[0]; ``` ================================================ FILE: docs/api/types/RegisteredRouter.md ================================================ # Types: RegisteredRouter\ ```ts type RegisteredRouter = T extends object ? TRouter : Router; ``` Represents the Router property within [Register](../interfaces/Register.md) ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `T` | [`Register`](../interfaces/Register.md) | ================================================ FILE: docs/api/types/ResolvedRoute.md ================================================ # Types: ResolvedRoute\ ```ts type ResolvedRoute = Readonly<{ hash: string; hooks: Hooks[]; href: UrlString; id: TRoute["id"]; matched: TRoute["matched"]; matches: TRoute["matches"]; name: TRoute["name"]; params: UrlParamsReading; query: URLSearchParams; state: ExtractRouteStateParamsAsOptional; }>; ``` Represents a route that the router has matched to current browser location. ## Type Parameters | Type Parameter | Default type | Description | | ------ | ------ | ------ | | `TRoute` *extends* [`Route`](Route.md) | [`Route`](Route.md) | Underlying Route that has been resolved. | ================================================ FILE: docs/api/types/ResolvedRouteUnion.md ================================================ # Types: ResolvedRouteUnion\ ```ts type ResolvedRouteUnion = TRoute extends Route ? ResolvedRoute : never; ``` Converts a union of Route types to a union of ResolvedRoute types while preserving the discriminated union structure for narrowing. This is useful when you have a Route union (like `TRoutes[number]`) and need it to narrow properly. Uses a distributive conditional type to ensure unions are properly distributed. ## Type Parameters | Type Parameter | | ------ | | `TRoute` *extends* [`Route`](Route.md) | ## Example ```ts type RouteUnion = RouteA | RouteB type ResolvedUnion = ResolvedRouteUnion // ResolvedRoute | ResolvedRoute ``` ================================================ FILE: docs/api/types/Route.md ================================================ # Types: Route\ ```ts type Route = TUrl & object; ``` Represents the structure of a route within the application. Return value of `createRoute` ## Type Declaration ### context ```ts context: TContext; ``` Related routes and rejections for the route. The context is exposed to the hooks and props callback functions for this route. ### depth ```ts depth: number; ``` **`Internal`** A value that represents how many parents a route has. Used for route matching ### hooks ```ts hooks: Hooks[]; ``` **`Internal`** The stores for routes including ancestors. Order of routes will be from greatest ancestor to narrowest matched. ### id ```ts id: string; ``` Unique identifier for the route, generated by router. ### matched ```ts matched: LastInArray; ``` The specific route properties that were matched in the current route. ### matches ```ts matches: TMatches; ``` The specific route properties that were matched in the current route, including any ancestors. Order of routes will be from greatest ancestor to narrowest matched. ### meta ```ts meta: TMeta; ``` Represents additional metadata associated with a route, combined with any parents. ### name ```ts name: TName; ``` Identifier for the route as defined by user. Name must be unique among named routes. Name is used for routing and for matching. ### prefetch? ```ts optional prefetch: PrefetchConfig; ``` Determines what assets are prefetched when router-link is rendered for this route. Overrides router level prefetch. ### state ```ts state: TState; ``` Represents the schema of the route state, combined with any parents. ## Type Parameters | Type Parameter | Default type | Description | | ------ | ------ | ------ | | `TName` *extends* `string` | `string` | Represents the unique name identifying the route, typically a string. | | `TUrl` *extends* [`Url`](Url.md) | [`Url`](Url.md) | - | | `TMeta` *extends* [`RouteMeta`](RouteMeta.md) | [`RouteMeta`](RouteMeta.md) | - | | `TState` *extends* `Record`\<`string`, [`Param`](Param.md)\> | `Record`\<`string`, [`Param`](Param.md)\> | - | | `TMatches` *extends* [`CreatedRouteOptions`](CreatedRouteOptions.md)[] | [`CreatedRouteOptions`](CreatedRouteOptions.md)[] | - | | `TContext` *extends* `RouteContext`[] | `RouteContext`[] | - | ## Template The type or structure of the route's path. ## Template The type or structure of the query parameters associated with the route. ================================================ FILE: docs/api/types/RouteMeta.md ================================================ # Types: RouteMeta\ ```ts type RouteMeta = T extends object ? RouteMeta : Record; ``` Represents additional metadata associated with a route, customizable via declaration merging. ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `T` | [`Register`](../interfaces/Register.md) | ================================================ FILE: docs/api/types/Router.md ================================================ # Types: Router\ ```ts type Router = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | `any` | | `TOptions` *extends* [`RouterOptions`](RouterOptions.md) | `any` | | `TPlugin` *extends* [`RouterPlugin`](RouterPlugin.md) | `any` | ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `back` | () => `void` | Navigates to the previous entry in the browser's history stack. | | `find` | (`url`, `options?`) => [`ResolvedRoute`](ResolvedRoute.md) \| `undefined` | Creates a ResolvedRoute record for a given URL. | | `forward` | () => `void` | Navigates to the next entry in the browser's history stack. | | `go` | (`delta`) => `void` | Moves the current history entry to a specific point in the history stack. | | `install` | (`app`) => `void` | Installs the router into a Vue application instance. | | `isExternal` | (`url`) => `boolean` | Given a URL, returns true if host does not match host stored on router instance | | `onAfterRouteEnter` | [`AddAfterEnterHook`](AddAfterEnterHook.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\], `ExtractRejections`\<`TOptions`\> \| `ExtractRejections`\<`TPlugin`\>\> | Registers a hook to be called after a route is entered. | | `onAfterRouteLeave` | [`AddAfterLeaveHook`](AddAfterLeaveHook.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\], `ExtractRejections`\<`TOptions`\> \| `ExtractRejections`\<`TPlugin`\>\> | Registers a hook to be called after a route is left. | | `onAfterRouteUpdate` | [`AddAfterUpdateHook`](AddAfterUpdateHook.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\], `ExtractRejections`\<`TOptions`\> \| `ExtractRejections`\<`TPlugin`\>\> | Registers a hook to be called after a route is updated. | | `onBeforeRouteEnter` | [`AddBeforeEnterHook`](AddBeforeEnterHook.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\], `ExtractRejections`\<`TOptions`\> \| `ExtractRejections`\<`TPlugin`\>\> | Registers a hook to be called before a route is entered. | | `onBeforeRouteLeave` | [`AddBeforeLeaveHook`](AddBeforeLeaveHook.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\], `ExtractRejections`\<`TOptions`\> \| `ExtractRejections`\<`TPlugin`\>\> | Registers a hook to be called before a route is left. | | `onBeforeRouteUpdate` | [`AddBeforeUpdateHook`](AddBeforeUpdateHook.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\], `ExtractRejections`\<`TOptions`\> \| `ExtractRejections`\<`TPlugin`\>\> | Registers a hook to be called before a route is updated. | | `onError` | [`AddErrorHook`](AddErrorHook.md)\<`TRoutes`\[`number`\] \| `TPlugin`\[`"routes"`\]\[`number`\], `TRoutes` \| `TPlugin`\[`"routes"`\], `ExtractRejections`\<`TOptions`\> \| `ExtractRejections`\<`TPlugin`\>\> | Registers a hook to be called when an error occurs. If the hook returns true, the error is considered handled and the other hooks are not run. If all hooks return false the error is rethrown | | `prefetch?` | [`PrefetchConfig`](PrefetchConfig.md) | Determines what assets are prefetched. | | `push` | [`RouterPush`](RouterPush.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\]\> | Navigates to a specified path or route object in the history stack, adding a new entry. | | `refresh` | () => `void` | Forces the router to re-evaluate the current route. | | `reject` | [`RouterReject`](RouterReject.md)\<\[`...ExtractRejections`, `...ExtractRejections`\]\> | Handles route rejection based on a specified rejection type. | | `replace` | [`RouterReplace`](RouterReplace.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\]\> | Replaces the current entry in the history stack with a new one. | | `resolve` | [`RouterResolve`](RouterResolve.md)\<`TRoutes` \| `TPlugin`\[`"routes"`\]\> | Creates a ResolvedRoute record for a given route name and params. | | `route` | \| [`RouterRouteUnion`](RouterRouteUnion.md)\<`TRoutes`\> \| [`RouterRouteUnion`](RouterRouteUnion.md)\<`TPlugin`\[`"routes"`\]\> | Manages the current route state. | | `start` | () => `Promise`\<`void`\> | Initializes the router based on the initial route. Automatically called when the router is installed. Calling this more than once has no effect. | | `started` | `Ref`\<`boolean`\> | Returns true if the router has been started. | | `stop` | () => `void` | Stops the router and teardown any listeners. | ================================================ FILE: docs/api/types/RouterAssets.md ================================================ # Types: RouterAssets\ ```ts type RouterAssets = object; ``` ## Type Parameters | Type Parameter | | ------ | | `TRouter` *extends* [`Router`](Router.md) | ## Compositions | Property | Type | Description | | ------ | ------ | ------ | | `useLink` | `ReturnType`\<*typeof* `createUseLink`\> | A composition to export much of the functionality that drives RouterLink component. Also exports some useful context about routes relationship to current URL and convenience methods for navigating. **Param** The name of the route or a valid URL. **Param** If providing route name, this argument will expect corresponding params. **Param** [RouterResolveOptions](RouterResolveOptions.md) Same options as router resolve. | | `useQueryValue` | `ReturnType`\<*typeof* `createUseQueryValue`\> | A composition to access a specific query value from the current route. | | `useRejection` | `ReturnType`\<*typeof* `createUseRejection`\> | A composition to access the rejection from the router. | | `useRoute` | `ReturnType`\<*typeof* `createUseRoute`\> | A composition to access the current route or verify a specific route name within a Vue component. This function provides two overloads: 1. When called without arguments, it returns the current route from the router without types. 2. When called with a route name, it checks if the current active route includes the specified route name. The function also sets up a reactive watcher on the route object from the router to continually check the validity of the route name if provided, throwing an error if the validation fails at any point during the component's lifecycle. **Template** A string type that should match route name of `RouterRouteName`, ensuring the route name exists. **Param** Optional. The name of the route to validate against the current active routes. **Throws** Throws an error if the provided route name is not valid or does not match the current route. | | `useRouter` | `ReturnType`\<*typeof* `createUseRouter`\> | A composition to access the installed router instance within a Vue component. **Throws** Throws an error if the router has not been installed, ensuring the component does not operate without routing functionality. | ## Components | Property | Type | Description | | ------ | ------ | ------ | | `RouterLink` | `ReturnType`\<*typeof* `createRouterLink`\> | A component to render a link to a route or any url. **Param** The props to pass to the router link component. | | `RouterView` | `ReturnType`\<*typeof* `createRouterView`\> | A component to render the current route's component. **Param** The props to pass to the router view component. | ## Guards | Property | Type | Description | | ------ | ------ | ------ | | `isRoute` | `ReturnType`\<*typeof* `createIsRoute`\> | A guard to verify if a route or unknown value matches a given route name. **Param** The name of the route to check against the current route. | ## Hooks | Property | Type | Description | | ------ | ------ | ------ | | `onAfterRouteLeave` | [`AddAfterLeaveHook`](AddAfterLeaveHook.md)\<[`RouterRoutes`](RouterRoutes.md)\<`TRouter`\>, [`RouterRejections`](RouterRejections.md)\<`TRouter`\>\> | Registers a hook that is called after a route has been left. Must be called during setup. This can be used for cleanup actions after the component is no longer active, ensuring proper resource management. **Param** The hook callback function | | `onAfterRouteUpdate` | [`AddAfterUpdateHook`](AddAfterUpdateHook.md)\<[`RouterRoutes`](RouterRoutes.md)\<`TRouter`\>, [`RouterRejections`](RouterRejections.md)\<`TRouter`\>\> | Registers a hook that is called after a route has been updated. Must be called during setup. This is ideal for responding to updates within the same route, such as parameter changes, without full component reloads. **Param** The hook callback function | | `onBeforeRouteLeave` | [`AddBeforeLeaveHook`](AddBeforeLeaveHook.md)\<[`RouterRoutes`](RouterRoutes.md)\<`TRouter`\>, [`RouterRejections`](RouterRejections.md)\<`TRouter`\>\> | Registers a hook that is called before a route is left. Must be called from setup. This is useful for performing actions or cleanups before navigating away from a route component. **Param** The hook callback function | | `onBeforeRouteUpdate` | [`AddBeforeUpdateHook`](AddBeforeUpdateHook.md)\<[`RouterRoutes`](RouterRoutes.md)\<`TRouter`\>, [`RouterRejections`](RouterRejections.md)\<`TRouter`\>\> | Registers a hook that is called before a route is updated. Must be called from setup. This is particularly useful for handling changes in route parameters or query while staying within the same component. **Param** The hook callback function | ================================================ FILE: docs/api/types/RouterLinkProps.md ================================================ # Types: RouterLinkProps\ ```ts type RouterLinkProps = RouterPushOptions & object; ``` ## Type Declaration ### prefetch? ```ts optional prefetch: PrefetchConfig; ``` Determines what assets are prefetched when router-link is rendered for this route. Overrides route level prefetch. ### to ```ts to: | UrlString | ResolvedRoute | ToCallback; ``` The url string to navigate to or a callback that returns a url string ## Type Parameters | Type Parameter | | ------ | | `TRouter` *extends* [`Router`](Router.md) | ================================================ FILE: docs/api/types/RouterOptions.md ================================================ # Types: RouterOptions ```ts type RouterOptions = object; ``` Options to initialize a [Router](Router.md) instance. ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `base?` | `string` | Base path to be prepended to any URL. Can be used for Vue applications that run in nested folder for domain. For example having `base` of `/foo` would assume all routes should start with `your.domain.com/foo`. | | `historyMode?` | `RouterHistoryMode` | Specifies the history mode for the router, such as "browser", "memory", or "hash". **Default** `"auto"` | | `initialUrl?` | `string` | Initial URL for the router to use. Required if using Node environment. Defaults to window.location when using browser. **Default** `window.location.toString()` | | `isGlobalRouter?` | `boolean` | When false, createRouterAssets must be used for component and hooks. Assets exported by the library will not work with the created router instance. **Default** `true` | | `prefetch?` | [`PrefetchConfig`](PrefetchConfig.md) | Determines what assets are prefetched when router-link is rendered for a specific route | | `rejections?` | `Rejections` | Components assigned to each type of rejection your router supports. | | `removeTrailingSlashes?` | `boolean` | Removes trailing slashes from the URL before matching routes. The browser's url is updated to reflect using `router.replace`. **Default** `true` | ================================================ FILE: docs/api/types/RouterPlugin.md ================================================ # Types: RouterPlugin\ ```ts type RouterPlugin = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | [`Routes`](Routes.md) | | `TRejections` *extends* `Rejections` | `Rejections` | ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `hooks` | `Hooks` | **`Internal`** The hooks supplied by the plugin. | | `rejections` | `TRejections` | **`Internal`** The rejections supplied by the plugin. * | | `routes` | `TRoutes` | **`Internal`** The routes supplied by the plugin. | ================================================ FILE: docs/api/types/RouterPush.md ================================================ # Types: RouterPush()\ ```ts type RouterPush = { (name, ...args): Promise; (route, options?): Promise; (url, options?): Promise; }; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | `any` | ## Call Signature ```ts (name, ...args): Promise; ``` ### Type Parameters | Type Parameter | | ------ | | `TSource` *extends* `string` | ### Parameters | Parameter | Type | | ------ | ------ | | `name` | `TSource` | | ...`args` | `RouterPushArgs`\<`TRoutes`, `TSource`\> | ### Returns `Promise`\<`void`\> ## Call Signature ```ts (route, options?): Promise; ``` ### Parameters | Parameter | Type | | ------ | ------ | | `route` | [`ResolvedRoute`](ResolvedRoute.md) | | `options?` | [`RouterPushOptions`](RouterPushOptions.md)\<`unknown`\> | ### Returns `Promise`\<`void`\> ## Call Signature ```ts (url, options?): Promise; ``` ### Parameters | Parameter | Type | | ------ | ------ | | `url` | [`UrlString`](UrlString.md) | | `options?` | [`RouterPushOptions`](RouterPushOptions.md)\<`unknown`\> | ### Returns `Promise`\<`void`\> ================================================ FILE: docs/api/types/RouterPushOptions.md ================================================ # Types: RouterPushOptions\ ```ts type RouterPushOptions = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` | `unknown` | ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `hash?` | `string` | The hash to append to the url. | | `query?` | [`QuerySource`](QuerySource.md) | The query string to add to the url. | | `replace?` | `boolean` | Whether to replace the current history entry. | | `state?` | `Partial`\<`TState`\> | State values to pass to the route. | ================================================ FILE: docs/api/types/RouterReject.md ================================================ # Types: RouterReject()\ ```ts type RouterReject = (type) => void; ``` ## Type Parameters | Type Parameter | | ------ | | `TRejections` *extends* `Rejections` \| `undefined` | ## Type Parameters | Type Parameter | | ------ | | `TSource` *extends* `RejectionType`\<`TRejections`\> \| `BuiltInRejectionType` | ## Parameters | Parameter | Type | | ------ | ------ | | `type` | `TSource` | ## Returns `void` ================================================ FILE: docs/api/types/RouterRejections.md ================================================ # Types: RouterRejections\ ```ts type RouterRejections = TRouter extends Router ? ExtractRejections | ExtractRejections : []; ``` ## Type Parameters | Type Parameter | | ------ | | `TRouter` *extends* [`Router`](Router.md) | ================================================ FILE: docs/api/types/RouterReplace.md ================================================ # Types: RouterReplace()\ ```ts type RouterReplace = { (name, ...args): Promise; (route, options?): Promise; (url, options?): Promise; }; ``` ## Type Parameters | Type Parameter | | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | ## Call Signature ```ts (name, ...args): Promise; ``` ### Type Parameters | Type Parameter | | ------ | | `TSource` *extends* `string` | ### Parameters | Parameter | Type | | ------ | ------ | | `name` | `TSource` | | ...`args` | `RouterReplaceArgs`\<`TRoutes`, `TSource`\> | ### Returns `Promise`\<`void`\> ## Call Signature ```ts (route, options?): Promise; ``` ### Parameters | Parameter | Type | | ------ | ------ | | `route` | [`ResolvedRoute`](ResolvedRoute.md) | | `options?` | [`RouterReplaceOptions`](RouterReplaceOptions.md)\<`unknown`\> | ### Returns `Promise`\<`void`\> ## Call Signature ```ts (url, options?): Promise; ``` ### Parameters | Parameter | Type | | ------ | ------ | | `url` | [`UrlString`](UrlString.md) | | `options?` | [`RouterReplaceOptions`](RouterReplaceOptions.md)\<`unknown`\> | ### Returns `Promise`\<`void`\> ================================================ FILE: docs/api/types/RouterReplaceOptions.md ================================================ # Types: RouterReplaceOptions\ ```ts type RouterReplaceOptions = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` | `unknown` | ## Properties | Property | Type | | ------ | ------ | | `hash?` | `string` | | `query?` | [`QuerySource`](QuerySource.md) | | `state?` | `Partial`\<`TState`\> | ================================================ FILE: docs/api/types/RouterResolve.md ================================================ # Types: RouterResolve()\ ```ts type RouterResolve = (name, ...args) => ResolvedRoute; ``` ## Type Parameters | Type Parameter | | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | ## Type Parameters | Type Parameter | | ------ | | `TSource` *extends* `RoutesName`\<`TRoutes`\> | ## Parameters | Parameter | Type | | ------ | ------ | | `name` | `TSource` | | ...`args` | `RouterResolveArgs`\<`TRoutes`, `TSource`\> | ## Returns [`ResolvedRoute`](ResolvedRoute.md) ================================================ FILE: docs/api/types/RouterResolveOptions.md ================================================ # Types: RouterResolveOptions\ ```ts type RouterResolveOptions = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TState` | `unknown` | ## Properties | Property | Type | | ------ | ------ | | `hash?` | `string` | | `query?` | [`QuerySource`](QuerySource.md) | | `state?` | `Partial`\<`TState`\> | ================================================ FILE: docs/api/types/RouterResolvedRouteUnion.md ================================================ # Types: RouterResolvedRouteUnion\ ```ts type RouterResolvedRouteUnion = { [K in keyof TRoutes]: ResolvedRoute }[number]; ``` This type is the same as `ResolvedRoute` while remaining distributive ## Type Parameters | Type Parameter | | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | ================================================ FILE: docs/api/types/RouterRoute.md ================================================ # Types: RouterRoute\ ```ts type RouterRoute = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TRoute` *extends* [`ResolvedRoute`](ResolvedRoute.md) | [`ResolvedRoute`](ResolvedRoute.md) | ## Accessors ### query #### Get Signature ```ts get query(): URLSearchParams; ``` ##### Returns `URLSearchParams` #### Set Signature ```ts set query(value): void; ``` ##### Parameters | Parameter | Type | | ------ | ------ | | `value` | \| `string` \| `URLSearchParams` \| `string`[][] \| `Record`\<`string`, `string`\> \| `undefined` | ##### Returns `void` ## Properties | Property | Modifier | Type | Description | | ------ | ------ | ------ | ------ | | `hash` | `readonly` | `string` | Hash value of the route. | | `hooks` | `readonly` | `TRoute`\[`"hooks"`\] | **`Internal`** The stores for routes including ancestors. | | `href` | `readonly` | `TRoute`\[`"href"`\] | String value of the resolved URL. | | `id` | `readonly` | `TRoute`\[`"id"`\] | Unique identifier for the route, generated by router. | | `matched` | `readonly` | `TRoute`\[`"matched"`\] | The specific route properties that were matched in the current route. | | `matches` | `readonly` | `TRoute`\[`"matches"`\] | The specific route properties that were matched in the current route, including any ancestors. Order of routes will be from greatest ancestor to narrowest matched. | | `name` | `readonly` | `TRoute`\[`"name"`\] | Identifier for the route as defined by user. Name must be unique among named routes. Name is used for routing and for matching. | | `params` | `public` | `TRoute`\[`"params"`\] | - | | `state` | `public` | `TRoute`\[`"state"`\] | - | | `update` | `readonly` | `RouteUpdate`\<`TRoute`\> | Update the route. | ================================================ FILE: docs/api/types/RouterRouteName.md ================================================ # Types: RouterRouteName\ ```ts type RouterRouteName = TRouter extends Router ? RoutesName : RoutesName; ``` ## Type Parameters | Type Parameter | | ------ | | `TRouter` *extends* [`Router`](Router.md) | ================================================ FILE: docs/api/types/RouterRouteUnion.md ================================================ # Types: RouterRouteUnion\ ```ts type RouterRouteUnion = { [K in keyof TRoutes]: TRoutes[K]["name"] extends "" ? never : RouterRoute> }[number]; ``` This type is the same as `RouterRoute>` while remaining distributive. Routes without a name (empty string) are excluded so that router.route.name is never ''. ## Type Parameters | Type Parameter | | ------ | | `TRoutes` *extends* [`Routes`](Routes.md) | ================================================ FILE: docs/api/types/RouterRoutes.md ================================================ # Types: RouterRoutes\ ```ts type RouterRoutes = TRouter extends Router ? TRoutes : Routes; ``` ## Type Parameters | Type Parameter | | ------ | | `TRouter` *extends* [`Router`](Router.md) | ================================================ FILE: docs/api/types/RouterViewPropsGetter.md ================================================ # Types: RouterViewPropsGetter()\ ```ts type RouterViewPropsGetter = (route, context) => MaybePromise>; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TOptions` *extends* [`CreateRouteOptions`](CreateRouteOptions.md) | [`CreateRouteOptions`](CreateRouteOptions.md) | ## Parameters | Parameter | Type | | ------ | ------ | | `route` | [`ResolvedRoute`](ResolvedRoute.md)\<[`ToRoute`](ToRoute.md)\<`TOptions`\>\> | | `context` | [`PropsCallbackContext`](PropsCallbackContext.md)\<[`ToRoute`](ToRoute.md)\<`TOptions`\>, `TOptions`\> | ## Returns `MaybePromise`\<`RouterViewProps` & `Record`\<`string`, `unknown`\>\> ================================================ FILE: docs/api/types/Routes.md ================================================ # Types: Routes ```ts type Routes = readonly Route[]; ``` Represents an immutable array of Route instances. Return value of `createRoute`, expected param for `createRouter`. ================================================ FILE: docs/api/types/ToCallback.md ================================================ # Types: ToCallback()\ ```ts type ToCallback = (resolve) => | ResolvedRoute | UrlString | undefined; ``` ## Type Parameters | Type Parameter | | ------ | | `TRouter` *extends* [`Router`](Router.md) | ## Parameters | Parameter | Type | | ------ | ------ | | `resolve` | `TRouter`\[`"resolve"`\] | ## Returns \| [`ResolvedRoute`](ResolvedRoute.md) \| [`UrlString`](UrlString.md) \| `undefined` ================================================ FILE: docs/api/types/ToRoute.md ================================================ # Types: ToRoute\ ```ts type ToRoute = CreateRouteOptions extends TOptions ? Route : TOptions extends object ? Route, CombineUrl>, CombineMeta, ToMeta>, CombineState, ToState>, ToMatches extends TProps ? undefined : TProps>, [...ToRouteContext, ...ToRouteContext]> : Route, ToUrl>, ToMeta, ToState, ToMatches extends TProps ? undefined : TProps>, ToRouteContext>; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TOptions` *extends* [`CreateRouteOptions`](CreateRouteOptions.md) | - | | `TProps` *extends* [`CreateRouteProps`](CreateRouteProps.md)\<`TOptions`\> \| `undefined` | `undefined` | ================================================ FILE: docs/api/types/ToUrl.md ================================================ # Types: ToUrl\ ```ts type ToUrl = Url["params"] & ToUrlPart["params"] & ToUrlQueryPart["params"] & ToUrlPart["params"]>>; ``` ## Type Parameters | Type Parameter | | ------ | | `TOptions` *extends* [`CreateUrlOptions`](CreateUrlOptions.md) | ================================================ FILE: docs/api/types/Url.md ================================================ # Types: Url\ ```ts type Url = object; ``` Represents the structure of a url parts. Can be used to create a url with support for params. ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TParams` *extends* `UrlParams` | `UrlParams` | ## Methods ### parse() ```ts parse(url, options?): Identity ? TParam extends Required ? ExtractParamType : ExtractParamType | undefined : TParams[K] extends RequiredUrlParam ? ExtractParamType : unknown }>>; ``` Parses the url supplied and returns any params found. #### Parameters | Parameter | Type | | ------ | ------ | | `url` | `string` | | `options?` | [`ParseUrlOptions`](ParseUrlOptions.md) | #### Returns `Identity`\<`MakeOptional`\<\{ \[K in string \| number \| symbol\]: TParams\[K\] extends OptionalUrlParam\ ? TParam extends Required\ ? ExtractParamType\ : ExtractParamType\ \| undefined : TParams\[K\] extends RequiredUrlParam\ ? ExtractParamType\ : unknown \}\>\> *** ### stringify() ```ts stringify(...params): UrlString; ``` Converts the url parts to a full url. #### Parameters | Parameter | Type | | ------ | ------ | | ...`params` | `UrlParamsArgs`\<`TParams`\> | #### Returns [`UrlString`](UrlString.md) *** ### tryParse() ```ts tryParse(url, options?): | { params: ToUrlParamsReading; success: true; } | { error: Error; params: { }; success: false; }; ``` Parses the url supplied and returns any params found. #### Parameters | Parameter | Type | | ------ | ------ | | `url` | `string` | | `options?` | [`ParseUrlOptions`](ParseUrlOptions.md) | #### Returns \| \{ `params`: `ToUrlParamsReading`\<`TParams`\>; `success`: `true`; \} \| \{ `error`: `Error`; `params`: \{ \}; `success`: `false`; \} ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `[IS_URL_SYMBOL]` | `true` | **`Internal`** Symbol to identify if the url is a valid url. | | `isRelative` | `boolean` | True if the url is relative. False if the url is absolute. | | `params` | `TParams` | **`Internal`** The parameters type for the url. Non functional and undefined at runtime. | ================================================ FILE: docs/api/types/UrlParamsReading.md ================================================ # Types: UrlParamsReading\ ```ts type UrlParamsReading = ToUrlParamsReading; ``` Extracts combined types of path and query parameters for a given url, creating a unified parameter object. ## Type Parameters | Type Parameter | Description | | ------ | ------ | | `TUrl` *extends* [`Url`](Url.md) | The url type from which to extract and merge parameter types. | ## Returns A record of parameter names to their respective types, extracted and merged from both path and query parameters. ================================================ FILE: docs/api/types/UrlParamsWriting.md ================================================ # Types: UrlParamsWriting\ ```ts type UrlParamsWriting = ToUrlParamsWriting; ``` Extracts combined types of path and query parameters for a given url, creating a unified parameter object. Differs from ExtractRouteParamTypesReading in that optional params with defaults will remain optional. ## Type Parameters | Type Parameter | Description | | ------ | ------ | | `TUrl` *extends* [`Url`](Url.md) | The url type from which to extract and merge parameter types. | ## Returns A record of parameter names to their respective types, extracted and merged from both path and query parameters. ================================================ FILE: docs/api/types/UrlString.md ================================================ # Types: UrlString ```ts type UrlString = `http://${string}` | `https://${string}` | `/${string}`; ``` ================================================ FILE: docs/api/types/UseLink.md ================================================ # Types: UseLink ```ts type UseLink = object; ``` ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `element` | `Ref`\<`HTMLElement` \| `undefined`\> | A template ref to bind to the dom for automatic prefetching | | `href` | `ComputedRef`\<[`UrlString`](UrlString.md) \| `undefined`\> | Resolved URL with params interpolated and query applied. Same value as `router.resolve`. | | `isActive` | `ComputedRef`\<`boolean`\> | True if route matches current URL, or is a parent route that matches the parent of the current URL. | | `isExactActive` | `ComputedRef`\<`boolean`\> | True if route matches current URL exactly. | | `isExactMatch` | `ComputedRef`\<`boolean`\> | True if route matches current URL. Route is the same as what's currently stored at `router.route`. | | `isExternal` | `ComputedRef`\<`boolean`\> | - | | `isMatch` | `ComputedRef`\<`boolean`\> | True if route matches current URL or is ancestor of route that matches current URL | | `push` | (`options?`) => `Promise`\<`void`\> | Convenience method for executing `router.push` with route context passed in. | | `replace` | (`options?`) => `Promise`\<`void`\> | Convenience method for executing `router.replace` with route context passed in. | | `route` | `ComputedRef`\<[`ResolvedRoute`](ResolvedRoute.md) \| `undefined`\> | ResolvedRoute if matched. Same value as `router.find` | ================================================ FILE: docs/api/types/UseLinkOptions.md ================================================ # Types: UseLinkOptions ```ts type UseLinkOptions = RouterPushOptions & object; ``` ## Type Declaration ### prefetch? ```ts optional prefetch: PrefetchConfig; ``` ================================================ FILE: docs/api/types/WithHost.md ================================================ # Types: WithHost\ ```ts type WithHost = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `THost` *extends* `string` \| `UrlPart` | `string` \| `UrlPart` | ## Properties | Property | Type | Description | | ------ | ------ | ------ | | `host` | `THost` | Host part of URL. | ================================================ FILE: docs/api/types/WithParent.md ================================================ # Types: WithParent\ ```ts type WithParent = object; ``` ## Type Parameters | Type Parameter | Default type | | ------ | ------ | | `TParent` *extends* [`Route`](Route.md) | [`Route`](Route.md) | ## Properties | Property | Type | | ------ | ------ | | `parent` | `TParent` | ================================================ FILE: docs/api/types/WithoutHost.md ================================================ # Types: WithoutHost ```ts type WithoutHost = object; ``` ## Properties | Property | Type | | ------ | ------ | | `host?` | `never` | ================================================ FILE: docs/api/types/WithoutParent.md ================================================ # Types: WithoutParent ```ts type WithoutParent = object; ``` ## Properties | Property | Type | | ------ | ------ | | `parent?` | `never` | ================================================ FILE: docs/api/variables/IS_URL_SYMBOL.md ================================================ # Variables: IS\_URL\_SYMBOL ```ts const IS_URL_SYMBOL: unique symbol; ``` ================================================ FILE: docs/components/router-link.md ================================================ # RouterLink The router link component is a wrapper around the anchor element. It is registered globally by the [router plugin](/quick-start#vue-plugin). ## Props | Prop | Required | Type | Description | | --- | --- | --- | --- | | to | true | [`Url`](/api/types/Url), [`ResolvedRoute`](/api/types/ResolvedRoute), or [`ToCallback`](/api/types/ToCallback) | The location to navigate to when clicked | | replace | false | `boolean` | When true, replaces the current history entry instead of adding a new one | | prefetch | false | `boolean`, [`PrefetchStrategy`](/api/types/PrefetchStrategy) or [`PrefetchConfig`](/api/types/PrefetchConfig) | Controls what assets are prefetched when the link is rendered | | query | false | [`QuerySource`](/api/types/QuerySource) | Query parameters to append to the URL | | hash | false | `string` | URL hash fragment to append | | state | false | `unknown` | State object to associate with the history entry | ### The `to` prop The `to` prop determines the the href attribute of the anchor element. The `to` prop can be a [Url](/api/types/Url), a [ResolvedRoute](/api/types/ResolvedRoute), or a getter that returns either type. ### Using a [ResolvedRoute](/api/types/ResolvedRoute) Using a [ResolvedRoute](/api/types/ResolvedRoute) is the recommended way to navigate to a predefined route. When the `to` prop is a getter the router's resolve function is passed in as an argument. Here are two ways of creating the same link. ```vue Profile ``` ```vue ``` ### Using a [Url](/api/types/Url) As a convenience, you can also use a [Url](/api/types/Url) for the `to` prop. This is not type safe and is not recommended. But it can be useful for creating links to external sites. ```vue External Link ``` ::: info External Routes You can define [external routes](/core-concepts/external-routes) in your router configuration for a type safe way to navigate to external urls. ::: ## Slots `RouterLink` provides a default slot to render the link text. But it also exposes the following slot scopes. | Property | Type | Description | | --- | --- | --- | | route | [`ResolvedRoute`](/api/types/ResolvedRoute) or `undefined` | The resolved route object for the link destination | | isMatch | `boolean` | Whether the current route matches the link's location | | isExactMatch | `boolean` | Whether the current route exactly matches the link's location | | isExternal | `boolean` | Whether the link points to an external URL | ```vue ... ``` ## Classes The `RouterLink` component will automatically add the `router-link--match` class to the anchor element when the current route matches the route specified in the `to` prop. It will also add the `router-link--exact-match` class when the current route matches the route specified in the `to` prop exactly. ================================================ FILE: docs/components/router-view.md ================================================ # RouterView The router view component is how route components are rendered. It is registered globally by the [router plugin](/quick-start#vue-plugin). ## Props | Prop | Required | Type | Default | Description | | --- | --- | --- | --- | --- | | name | false | `string` | `default` | The name of component to render | ### The `name` prop The `name` prop is used to specify the name of the component to render. Multiple components can be defined for a single route by using the `components` option. ## Slots `RouterView` provides a default slot to render the route component. It receives the following slot scopes. | Property | Type | Description | | --- | --- | --- | | route | [`ResolvedRoute`](/api/types/ResolvedRoute) | The resolved route object for the current route | | component | `Component` | The component to render | | rejection | `RouterRejection` | The rejection object for the current route | ## Transitions The default slot can be used to layer in a [Vue transition](https://vuejs.org/guide/built-ins/transition.html) if desired. ```html ``` ## Component Reuse Vue will reuse components if a route change ends up rendering the same underlying component. This has advantages but can sometimes cause issues. You can avoid this by [using the `key` attribute](https://vuejs.org/api/built-in-special-attributes.html#key). Using the `route.href` property is a good way to generate a unique key for each route. ```html{3} ``` ================================================ FILE: docs/composables/useLink.md ================================================ # useLink Used to create a link to a route. Generally the [`RouterLink`](/components/router-link) component should be used instead of this composable. See the ['UseLink'](/api/types/UseLink) api reference for more information on the return type. ```ts import { useLink } from '@kitbag/router' const link = useLink('profile', { userId: 123 }) ``` :::tip [Register](/quick-start.html#type-safety) your router to get the proper types when using this composable. ::: ================================================ FILE: docs/composables/useQueryValue.md ================================================ # useQueryValue Returns the value of a specific key in the query string. The query can be accessed using the Router Route's [query property](/api/types/RouterRoute#query) but this composable allows using [param types](/core-concepts/params#param-types) to ensure type safety. This is useful when you need to interact with the query but without defining a [query param](/core-concepts/params#query-params). ## Arguments | Name | Type | Required | Description | |------|------|----------|-------------| | `key` | `MaybeRefOrGetter` | Yes | The query parameter key to get/set values for. Can be a string, ref, or computed value | | `param` | `Param` | No | The parameter type to use for type conversion. Defaults to `String`. See [param types](/core-concepts/params#param-types) for available options | ## Return Type | Property | Type | Description | |----------|------|-------------| | `value` | `Ref` | The single value for the query parameter | | `values` | `Ref` | All values for the query parameter as an array | | `remove` | `() => void` | Function to remove the query parameter | Where `T` is the type determined by the param type (defaults to `string`). ## Basic Usage ```ts import { useQueryValue } from '@kitbag/router' const { value: userId } = useQueryValue('userId') // ^? Ref const { values: userIds } = useQueryValue('selectedUserIds') // ^? Ref ``` ## Param Types The param type can be passed in to ensure the type of the value is correct. ```ts const { value: userId } = useQueryValue('userId', Number) // ^? Ref const { values: userIds } = useQueryValue('selectedUserIds', Number) // ^? Ref ``` ================================================ FILE: docs/composables/useRoute.md ================================================ # useRoute Returns the current route. See ['RouterRoute'](/api/types/RouterRoute) for more information. ```ts import { useRoute } from '@kitbag/router' const route = useRoute() ``` This composable can also be used to narrow the type of the route by passing in the name of the expected route. It works the same way as the [`isRoute`](/api/type-guards/isRoute.html) type guard. However if the current route does not match the expected route, a [`UseRouteInvalidRouteError`](/api/errors/UseRouteInvalidError.html) will be thrown. ```ts const route = useRoute('profile') ``` ## Exact The route will be a union of all possible routes as long as the name matches any of the matches in the current route. This includes any parent routes. To match a specific route, you can use the `exact` option. ```ts const route = useRoute('profile', { exact: true }) ``` Read more about [route narrowing](/advanced-concepts/route-narrowing.md). ================================================ FILE: docs/composables/useRouter.md ================================================ # useRouter Returns the installed router instance. See ['Router'](/api/types/Router) for more information. ```ts import { useRouter } from '@kitbag/router' const router = useRouter() ``` :::tip [Register](/quick-start.html#type-safety) your router to get the proper types when using this composable. ::: ================================================ FILE: docs/core-concepts/component-props.md ================================================ # Component Props With Kitbag Router, you can define a `props` callback on your route. Your callback is given the [`ResolvedRoute`](/api/types/ResolvedRoute) for the route and what it returns will be bound to the component when the component gets mounted inside the `` ```ts {5} const user = createRoute({ name: 'user', path: '/user/[id]', component: UserComponent, }, (route) => ({ userId: route.params.id })) ``` This is obviously useful for assigning static values or route params down to your view components props but it also gives you - Correct types on the route's params, query, etc. - Correct type for return type. - Support for async prop fetching. ## Named Views When using the [`components`](/core-concepts/routes.html#components) property the `props` argument must be an object. Each component can have its own props callback. ```ts {5-6,9-10} const user = createRoute({ name: 'user', path: '/user/[id]', components: { default: UserComponent, sidebar: UserSidebarComponent, } }, { default: (route) => ({ userId: route.params.id }), sidebar: (route) => ({ userId: route.params.id }) }) ``` ## Params Type The params passed to your callback has all of the type context including params from parents and any defaults applied. ## Return Type Your callback will throw a Typescript error if it returns anything other than the type defined by the component for props. This also means that if your route's component has required props, you'll get an error until you satisfy this requirement. ## Async prop fetching The props call back supports promises. This means you can do much more than just forward values from params or insert static values. For example, we can take an id route param and fetch the `User` before mounting the component. ```ts {5-9} const user = createRoute({ name: 'user', path: '/user/[id]', component: UserComponent, }, async (route) => { const user = await userStore.getById(route.params.id) return { user } }) ``` ## Parent Props The [callback context](/core-concepts/component-props#context) includes a `parent` property which contains the name and props of the parent route. This can be useful for passing down data to child components. ```ts const blogPost = createRoute({ name: 'blog', path: '/blog/[blogPostId]' }, async (route) => { const post = await getBlockPostById(route.params.blogPostId) return { post } }) const blogPostTabs = createRoute({ parent: blogPost, name: 'tabs', query: '?tab=[tab]' component: PostTabs, }, async (route, { parent }) => { const tab = route.query.tab const { post } = await parent.props return { tab, post } }) ``` :::warning Awaiting parent props will create a waterfall of async operations which can make your app feel sluggish. ::: ## Context The router provides a second `context` argument to your props callback. The context will always include: | Property | Description | | ---- | ---- | | push | Convenient way to move the user from wherever they were to a new route. | | parent | And object containing the name and props of the parent route. | | replace | Same as push, but with `options: { replace: true }`. | | reject | Trigger a [rejection](/advanced-concepts/rejections) for the router to handle | ::: warning Unlike [hooks](/advanced-concepts/hooks), props are not awaited during navigation. This means that any parent components will be mounted and any [After Hooks](/advanced-concepts/hooks#after-hooks) will start while any async prop fetching is happening. ::: ```ts const user = createRoute({ name: 'user', path: '/user/[id]', component: UserComponent, }, async (route, { reject }) => { try { const user = await userStore.getById(route.params.id) return { user } } catch (error) { reject('NotFound') } })) ``` ## Global Injection Props are run within the context of the Vue app the router is installed. This means you can use vue's `inject` function to access global values. ```ts import { inject } from 'vue' const route = createRoute({ ... }, async () => { const value = inject('global') return { value } }) ``` ================================================ FILE: docs/core-concepts/external-routes.md ================================================ # External Routes External routes allow you to define routes for things outside of your application. External routes have all the same functionality of normal routes, including [params](/core-concepts/params). When navigating to an external route, the router will simply push the url to the browsers history. When defining an external route a `host` must be provided, or it can be inherited from a `parent`. ```ts import { createExternalRoute } from '@kitbag/router' const docs = createExternalRoute({ name: 'docs', host: 'https://router.kitbag.dev', }) ``` ## Hooks External routes can also define hooks but since by definition they will navigate the user away from your application, the only hook that is available is `onBeforeRouteEnter`. See [Hooks](/advanced-concepts/hooks) for more information about hooks. ```ts import { createExternalRoute } from '@kitbag/router' const docs = createExternalRoute({ name: 'docs', host: 'https://router.kitbag.dev', }) docs.onBeforeRouteEnter(() => { console.log('before route enter') }) ``` ================================================ FILE: docs/core-concepts/navigation.md ================================================ # Navigation We'll use these example routes to demonstrate navigation: ```ts const blog = createRoute({ name: 'blog', path: '/blog', }) const blogPost = createRoute({ parent: blog, name: 'blogPost', path: withParams('/[blogPostId]', { blogPostId: Number, }), }) ``` ## Using a link The router link component makes it easy to create links to routes, external routes, or any url. See the [RouterLink](/components/router-link) docs for more info. ```vue Blog Post One ``` ## Programmatic Navigation Using [`router.push`](/core-concepts/router#push), [`router.replace`](/core-concepts/router#replace), or [`route.update`](/core-concepts/router-route#update) you can do programmatic navigation. ::: code-group ```ts [Push] import { useRouter } from '@kitbag/router' const router = useRouter() router.push('blogPost', { blogPostId: 1, }) ``` ```ts [Replace] import { useRouter } from '@kitbag/router' const router = useRouter() router.replace('blogPost', { blogPostId: 1, }) ``` ```ts [Update] import { useRoute } from '@kitbag/router' const route = useRoute('blogPost') route.update({ blogPostId: 1 }) ``` ::: ## Routes vs Urls All navigation methods accept a route or a url. Using a route is the recommended because it is type safe. But sometimes it is necessary to use a url. These examples are all the same functionally. ::: code-group ```ts [Router] // type safe ✅ router.push('blogPost', { blogPostId: 1, }) // not type safe ⚠️ router.push('/blogPost/1') ``` ```vue [Router Link] Blog Post One Blog Post One ``` ::: ## Push vs Replace A push is the default when navigating. This will add a new entry to the browser history using [pushState](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState). A replace will also navigate to the new route, but it will replace the current entry in the browser history using [replaceState](https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState). ::: code-group ```ts [Router] // push router.push('blog') // replace router.replace('blog') // or router.push('blog', { replace: true }) ``` ```vue [RouterLink] Blog Blog ``` ::: ## Resolved Routes A [ResolvedRoute](/api/types/ResolvedRoute) is the base of what makes up the [Router Route](/core-concepts/router-route). It represents a [route](/core-concepts/routes) that has been matched to a specific url. It includes any params, state, query, and hash values for that url. Resolved routes are how Kitbag Router ensures type safety when navigating. There are a few ways to get a resolved route. ```ts /** * This is the most explicit way to get a resolved route. * It takes a route name and will ensure any required params are provided. */ const resolvedBlockPostRoute = router.resolve('blogPost', { blogPostId: 1, }) ``` Resolved routes add some useful properties to the route. - `href`: The full url of the route. - `query`: The query parameters of the route. - `hash`: The hash of the route. - `params`: The parameters of the route. - `state`: The state of the route. ## Create Url Kitbag Router also exports the `createUrl` function, which exposes all of the utility of defining a URL with params outside of a route definition. ```ts import { createUrl, withDefault } from '@kitbag/router' const getUser = createUrl({ host: 'https://api.kitbag.dev', path: '/users/[userId]', query: { version: withDefault(Number, 1), }, }) const href = getUser.stringify({ userId: 'PAOP4KAW' }) // 'https://api.kitbag.dev/users/PAOP4KAW?version=1' const params = getUser.parse('https://api.kitbag.dev/users/L969HD9Z?version=4') // { userId: 'L969HD9Z', version: 4 } ``` The `createUrl` function returns a [Url](/api/types/Url) object. ================================================ FILE: docs/core-concepts/params.md ================================================ # Params Params are used to define the dynamic parts of a route. Params can be used in the `path`, `query`, `hash`, and `host` properties when defining a route. ## Param Names Param names are defined using square brackets. What is inside the brackets is the name of the param and is used when accessing the param value and when providing the param value when navigating to the route. ```ts {3} const events = createRoute({ name: 'events', path: '/events/[year]/[month]', }) ``` :::warning Param names must be unique. This includes all properties that have params as well as between a child route and it's parent route. ::: ## Optional Params Params can be made optional by adding a `?` before the name. The `year` param is not optional and will have the type `string` when being accessed. But the `month` param is optional and will have the type `string | undefined` when being accessed. ```ts {3} const events = createRoute({ name: 'events', path: '/events/[year]/[?month]', }) ``` ## Greedy Params By default, a path param matches only up to the next `/` (a single path segment). For example, the path `/photos/[date]` matches `/photos/2026` (param is `"2026"`) but not `/photos/2026/01`. A **greedy param** matches across one or more path segments (including `/`). Add a `*` after the param name: `[param*]` or `[?param*]`. The path `/photos/[date*]` then matches both `/photos/2026` (param is `"2026"`) and `/photos/2026/01` (param is `"2026/01`). ```ts {3} const route = createRoute({ name: 'photos', path: '/photos/[date*]', }) ``` Use greedy params when a param value can contain slashes (e.g. file paths or encoded segments). ## Param Types By default all params are strings. However, using the `withParams` utility, you can assign different param types. Now the `year` param must be a valid number. The router will not match the route if it is not a number, it will have the type `number` when being accessed, and the type `number` will be enforced when navigating and providing a value for the param. ```ts {1,5-7} import { withParams } from '@kitbag/router' const events = createRoute({ name: 'events', path: withParams('/events/[year]/[?month]', { year: Number, }), }) ``` :::info Route Matching When using a param type, if the url cannot be parsed as the specified type it will not be matched. For example if the url was `/events/two-thousand-and-twenty-four/september` it would not be matched because `two-thousand-and-twenty-four` is not a number. See [route matching](/advanced-concepts/route-matching) for more information. ::: ### Built-in Param Types Kitbag Router comes with a few built-in param types: | Type | Default | Description | | -------- | ------- | --------------------------------------------------------------------------------------------- | | `String` | ✓ | Any string value | | `Number` | | Any value that can be parsed with the `Number` constructor | | `Boolean`| | The literal values `"true"` or `"false"` | | `Date` | | Any value that can be parsed with the `Date` constructor. Uses `toISOString` when serializing | | `RegExp` | | A literal regex expression | | `JSON` | | Any value that can be parsed with the `JSON.parse` method | ## Custom Param Types Define your own param types using the `createParam` utility. This serves three purposes: 1. Define the type for the param in Typescript when accessing the param. 1. Define how the param should be serialized and deserialized. 1. Determine if a value is valid. Using custom param types you can define more complex validation rules. For example, this month param must be a valid month name. If it is not, the route will not match. ```ts {3-11,17} import { createParam, createRoute, withParams } from '@kitbag/router' const months = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'] const monthParam = createParam((value, { invalid }) => { if (months.includes(value)) { return value } throw invalid(`Invalid month: ${value}`) }) const events = createRoute({ name: 'events', path: withParams('/events/[year]/[?month]', { year: Number, month: monthParam, }), }) ``` By default custom params will use `toString` when serializing. But you can use a Get/Set param to define how the param should be serialized and deserialized. For example we can make the month param capitalized when parsing and lowercase when serializing. ```ts {2,8,17} import { createParam } from '@kitbag/router' import { capitalize } from '@/utilities' const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const monthParam = createParam({ get: (value, { invalid }) => { const month = capitalize(value) if (months.includes(month)) { return month } throw invalid(`Invalid month: ${month}`) }, set: (value) => { return value.toLowerCase() }, }) ``` ## Default Values Define a default value when creating a param or by using the `withDefault` utility. This value will be used when the param is not provided in the url. An optional param of type `String` will have the type `string | undefined` when being accessed. But when a default value is provided it will have the type `string`, even if the value is missing from the url. ```ts {7} import { createRoute, withParams, withDefault } from '@kitbag/router' const events = createRoute({ name: 'events', path: withParams('/events/[year]/[?month]', { year: Number, month: withDefault(monthParam, 'january'), }), }) ``` ## Query Params So far the examples have only used params in the `path` property. When using params in the `query`, the param goes where you expect the value to be in the url's search string. Defining params in the query can be done with the same syntax as other parts of the URL. ```ts {3} const events = createRoute({ name: 'events', query: 'category=[?category]', }) ``` When defining params this way, the param name and the search key do not have to be the same. In this example the url search string might be `?category=hello`, which has a `term` param with the value `hello`. ```ts {2} const events = createRoute({ query: 'category=[?term]', }) ``` Params can also be defined as a record or tuple of key-value pairs. ```ts const events = createRoute({ query: { category: String, }, }) ``` When defining params in a record or tuple, the param name is the search key. So to define a param as optional, you can use the `?` prefix on the search key. ```ts {3} const events = createRoute({ query: { '?category': String, }, }) ``` Alternatively, you can use the `withDefault` utility to define a default value for the param, which also makes the param optional. ```ts {3} const events = createRoute({ query: { category: withDefault(String, 'music'), }, }) ``` ## Unions The `unionOf` utility can be used to define a param as a union of any number of [Param](/api/types/Param) arguments. ```ts import { unionOf, withParams } from '@kitbag/router' const events = createRoute({ name: 'events', query: { category: unionOf(['music', 'sports', 'art']), }, }) ``` ## Arrays The `arrayOf` utility can be used to define a param as an array of any number of [Param](/api/types/Param) arguments. ```ts import { arrayOf withParams } from '@kitbag/router' const events = createRoute({ name: 'events', query: { category: arrayOf(['music', 'sports', 'art']), }, }) ``` ### Array Options Optionally pass in options to specify a separator. The default separator is a comma. ```ts arrayOf(['music', 'sports', 'art'], { separator: '|' }) ``` ## Tuples The `tupleOf` utility can be used to define a param as a tuple of any number of [Param](/api/types/Param) arguments. Note the `tupleOf` utility also takes the same [options](/core-concepts/params#array-options) as the `arrayOf` utility. ```ts import { tupleOf, withParams } from '@kitbag/router' const events = createRoute({ name: 'events', query: { location: tupleOf([Number, Number]), }, }) ``` ## Zod Param Types [Zod](https://zod.dev/) schemas can be used as param types rather than defining a custom param type. Some zod schemas are not supported such as `z.promise`, `z.function`, and `z.intersection`, but most schemas are supported. ```ts import { z } from 'zod' const events = createRoute({ name: 'events', query: { category: z.enum(['music', 'sports', 'art']), }, }) ``` :::warning Zod param types are experimental and may change or be removed in the future. ::: ## Valibot Param Types [Valibot](https://valibot.dev/) schemas can be used as param types rather than defining a custom param type. Some zod schemas are not supported such as `v.promise`, `v.function`, and `v.intersection`, but most schemas are supported. ```ts import * as v from 'valibot' const events = createRoute({ name: 'events', query: { category: v.picklist(['music', 'sports', 'art']), }, }) ``` :::warning Valibot param types are experimental and may change or be removed in the future. ::: ================================================ FILE: docs/core-concepts/router-route.md ================================================ # Router Route The current route is represented by the [RouterRoute](/api/types/RouterRoute.md). This current route is accessed using the [useRoute](/composables/useRoute.md) composable within your components. It is also available on the router instance as the `route` property. ```ts import { useRoute } from '@kitbag/router' const route = useRoute() ``` There are a number of properties and methods available on the router route. These properties are reactive and will update when the route changes. We'll use these example routes to demonstrate the properties: ```ts const home = createRoute({ name: 'home', path: '/', }) const blog = createRoute({ name: 'blog', path: '/blog', }) const blogPost = createRoute({ parent: blog, name: 'blogPost', path: path('/[blogPostId]', { blogPostId: Number, }), }) ``` ## Name The name of the route is available on the `name` property. This can be used to identify the current route. It can also be used to type narrow the route similar to how you could use the [isRoute](/api/type-guards/isRoute.md) type guard. ```ts const route = useRoute() // ^ ? { name: 'home', ... } | { name: 'blog', ... } | { name: 'blogPost', ... } if (route.name === 'home') { route // ^? { name: 'home', ... } } ``` ## Matched The specific route being viewed is available on the `matched` property. This is the single route that was matched to the current router location. If the location is `/blog/123`, the `matched` property will be the `blogPost` route. ```ts const matched = route.matched ``` ## Matches All the routes that were matched to the current router location are available on the `matches` property. This includes the matched route, as well as any parent routes. If the location is `/blog/123`, the `matches` property will be `[blog, blogPost]`. ```ts const matches = route.matches ``` ## Hash The hash of the current route is available on the `hash` property. This is the [hash](https://developer.mozilla.org/en-US/docs/Web/API/Location/hash) property of the current router location. If the location is `/blog/123#comments`, the `hash` property will be `'comments'`. ```ts const hash = route.hash ``` ## Href The href property is the current router location as a [Url](/api/types/Url.md) string. This will reflect the current browser [location](https://developer.mozilla.org/en-US/docs/Web/API/Location). ```ts const href = route.href ``` ## Params Any [params](/core-concepts/params) that were matched to the current route are available on the `params` property. If the location is `/blog/123`, the `params` property will be `{ blogPostId: 123 }`. ```ts const params = route.params ``` The `params` property is also writable. Updating params will update the route and the router location. This is the same as using the [update](/core-concepts/router-route#update) method. ```ts route.params = { blogPostId: 456 } ``` You can also update individual params ```ts route.params.blogPostId = 789 ``` :::tip Type Safety The router route and its params are type safe. So you might need to narrow the route to access and set the params. ::: ## Query The `query` property is the [search](https://developer.mozilla.org/en-US/docs/Web/API/Location/search) of the current router location. If the location is `/blog?page=1`, the `query` property will be `URLSearchParams { 'page' => '1' }`. ```ts const page = route.query.get('page') ``` The `query` property is also writable. Updating the `query` property will update the route and the router location. This is the same as using the [update](/core-concepts/router-route#update) method. ```ts route.query = new URLSearchParams({ page: '2' }) ``` You can also update individual query params ```ts route.query.set('page', '3') ``` ## State The `state` property is the [state](/core-concepts/routes#state) of the current route. This `state` property is also writable. ```ts const state = route.state ``` ## Update The `update` method is used to update the route and the router location. This is the same as using the router's [push](/core-concepts/router#push) method but you don't have to provide the route name and you don't have to provide a value for all params. All the options are the same. ```ts route.update({ blogPostId: 456 }, options) ``` You can also update individual params ```ts route.update('blogPostId', 789, options) ``` :::tip Multiple Params Any existing params on the route that are not provided will be preserved. ::: ================================================ FILE: docs/core-concepts/router.md ================================================ # Router The router is responsible for matching routes to urls and updating the browser history. It also provides a way to navigate to routes, access the current route, and provides some routing utilities. See [Router](/api/types/Router.md) type for more information. ## Creating a Router Create a router using the `createRouter` utility. ```ts {8} import { createRoute, createRouter } from '@kitbag/router' const routes = [ createRoute(...), createRoute(...), ] as const const router = createRouter(routes) ``` ## Installing Once you have created a router you can install it into a Vue application using the `use` method. ```ts {8} import { createRouter } from '@kitbag/router' import { createApp } from 'vue' import App from './App.vue' const router = createRouter(...) const app = createApp(App) app.use(router) app.mount('#app') ``` ## Type Safety Kitbag Router utilizes [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) to provide the internal types to match the actual router you're using. ```ts declare module '@kitbag/router' { interface Register { router: typeof router } } ``` ## Router Options There are several options you can pass to the router to customize its behavior. See the [RouterOptions](/api/types/RouterOptions.md) type for more information and a list of options. ```ts const router = createRouter(routes, { base: '/app', }) ``` ## Router Instance The router created by `createRouter` can be used directly in your application. You can also access the router instance using the [useRouter](/composables/useRouter.md) composable within your components. ```ts import { useRouter } from '@kitbag/router' const router = useRouter() ``` ## Router Methods ### Push Navigates to a specific route and adds a new history entry. Push accepts a route name, a resolved route, or a url. When using a route name, params can be passed as the second argument. The last argument is always the push options. See [RouterPushOptions](/api/types/RouterPushOptions.md) for more information. ```ts router.push('blogPost', { blogPostId: 1 }, { query: { highlight: 'search term', }, }) ``` ### Replace Replaces is exactly the same as push except it hard codes the `replace` option to `true`. See [Push vs Replace](/core-concepts/navigation#push-vs-replace) for more information. ```ts router.replace('home') ``` ### Refresh Forces the router to re-evaluate the current route. ```ts router.refresh() ``` ### Back Navigates to the previous history entry. ```ts router.back() ``` ### Forward Navigates to the next history entry. ```ts router.forward() ``` ### Go Moves the current history entry to a specific point in the history stack. ```ts router.go(1) ``` ### Reject Handles route rejection based on a specified rejection type. See [Rejections](/advanced-concepts/rejections) for more information. ```ts router.reject('NotFound') ``` ### Find Returns a resolved route for a url or `undefined` if the url does not match any route. ```ts router.find('/users/1') ``` ### Resolve Creates a ResolvedRoute record for a given route. A [ResolvedRoute](/api/types/ResolvedRoute) is the base of what makes up the [Router Route](/core-concepts/router-route). It represents a [route](/core-concepts/routes) that has been matched to a specific url. It includes any params, state, query, and hash values for that location. Resolved routes are how Kitbag Router ensures type safety when navigating. ```ts router.resolve('users', { id: 1 }) ``` ### IsExternal Checks if a given URL is external to the router instance. ```ts router.isExternal('https://google.com') ``` ### Install Installs the router into a Vue application instance. ```ts router.install(app) ``` ### Start Initializes the router based on the initial route. Automatically called when the router is installed as a vue plugin. Calling this more than once has no effect. ```ts router.start() ``` ================================================ FILE: docs/core-concepts/routes.md ================================================ # Routes Routes are used to define the structure of your application and to provide a way to navigate between different parts of your application or to an external address. You can define routes within your application using the `createRoute` function. ```ts import { createRoute } from '@kitbag/router' const home = createRoute({ name: 'home', path: '/', }) ``` ## Name The `name` property is used to identify the route. Each route mush have a unique name. ```ts {2} const home = createRoute({ name: 'home', path: '/', }) ``` ### Routes without names The name property is optional, but a route without a name cannot be navigated to. It can be useful to have unnamed routes for organizing related routes under a shared unnamed parent. Even though the parent can't be navigated to, this still ensures - the parents properties are merged with the child (`path`, `query`, `meta`, and `hash`) - any hooks defined on the parent run when the child is matched - the parents [state](/advanced-concepts/route-state#route-state) is merged with the child ## Path The `path` property is used to define the [pathname](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname) part of the route's url. ```ts {3} const home = createRoute({ name: 'home', path: '/', }) ``` ## Query The `query` property is used to define the [search](https://developer.mozilla.org/en-US/docs/Web/API/URL/search) part of the route's url. If a query is provided, a url must include a search string that matches the query. ```ts {4} const homeAddCampaign = createRoute({ name: 'home.black-friday', path: '/', query: { campaign: 'black-friday', }, }) ``` ## Hash The `hash` property is used to define the [hash](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash) part of the route's url. If a hash is provided, a url's hash must match exactly. ```ts {4} const contact = createRoute({ name: 'home.contact', path: '/', hash: 'contact', }) ``` ## Parent The `parent` property is used to create nested routes. In this example, `blogPost` route's path is combined with the `blog` route's path to form the full url. A route inherits many of its parent's properties. Specifically, `path`, `query`, `meta`, `state`, `context`, and `hash` are all combined. ```ts {7} const blog = createRoute({ name: 'blog', path: '/blog', }) const blogPost = createRoute({ parent: blog, name: 'blogPost', path: '/:postId', }) ``` ## Component The `component` property is used to define the component that will be rendered when the route is active. Component is optional and a [RouterView](/components/router-view) will be rendered if no component is provided. ```ts {6} import HomeView from './components/HomeView.svelte' const home = createRoute({ name: 'home', path: '/', component: HomeView, }) ``` ## Components The `components` property is used to define multiple components for named views. This is used instead of the `component` property. ```ts {7-10} import HomeView from './components/HomeView.vue' import HomeSidebar from './components/HomeSidebar.vue' const home = createRoute({ name: 'home', path: '/', components: { default: HomeView, sidebar: HomeSidebar, }, }) ``` ## Props The `props` argument is used to provide props for route components. It must be a callback function that returns an object. Everything returned from the callback will be bound to the component. ```ts {7} import HomeView from './components/HomeView.vue' const home = createRoute({ name: 'home', path: '/', component: HomeView, }, () => ({ userId: 1 })) ``` ### Arguments The props callback receives two arguments: | Argument | Description | | -------- | ----------- | | params | An object containing the values of any params from the route. See [Params](/core-concepts/params) for more details. | | context | An object containing helper methods for navigation (`push`, `replace`, `reject`). See [PropsCallbackContext](/api/types/PropsCallbackContext) for more details. | ### Return Type The props callback must return an object or a promise that resolves to an object. The object must satisfy the props for the component. ## Meta The `meta` property is used to define metadata for the route. Meta is optional and can be used to define static metadata for the route to reference in the [router route](/core-concepts/router-route) or in [hooks](/advanced-concepts/hooks) ```ts {7-9} import HomeView from './components/HomeView.vue' const home = createRoute({ name: 'home', path: '/', component: HomeView, meta: { title: 'Home', }, }) ``` ## State The `state` property is used to define optional data that can stored on the route in the browser's history. State is always optional, but it can be used to pass data to the route when navigating or to preserve state when navigating away from the route. ```ts {7-11} import ContactView from './components/HomeView.vue' const contact = createRoute({ name: 'contact', path: '/', component: ContactView, state: { firstName: String, lastName: String, message: String, }, }) ``` ## Hooks Hooks can be defined for a individual route. See [Hooks](/advanced-concepts/hooks) for more information about hooks. ```ts import HomeView from './components/HomeView.vue' const home = createRoute({ name: 'home', path: '/', component: HomeView, }) home.onBeforeRouteEnter(() => { console.log('before route enter') }) ``` ## Title The `setTitle` callback is used to set the document title for the route. The callback is given the resolved route and a context object. The callback can be async, and should return a string that should be set as the document.title. The context object has the following properties: | Property | Description | | -------- | ----------- | | from | What was the route prior to the hook's execution | | getParentTitle | A function that returns the title of the parent route. | ```ts import { createRoute } from '@kitbag/router' const user = createRoute({ name: 'user.profile', path: '/user/:userId', }) user.setTitle((to, context) => { const user = userStore.getUser(to.params.userId) return `Profile: ${user.name}` }) ``` :::info There is also a `setTitle` callback on [rejections](/advanced-concepts/rejections#title). ::: ## Context The context for a route is the collection of routes and rejections that are associated with the route. The context you provide to this route will be available to the hooks and props callback functions for this route. ```ts const newHomePage = createRoute({ name: 'new-home', path: '/', component: NewHomePage, }) const home = createRoute({ name: 'home', path: '/', context: [newHomePage], }) home.onBeforeRouteEnter((to, { replace }) => { if(user.isCanary) { // TS knows about 'new-home' because it's in the context replace('new-home') } }) ``` ## Prefetching Routes can be prefetched to improve performance. See the [Prefetching](/advanced-concepts/prefetching) documentation for more information. ```ts const home = createRoute({ name: 'home', path: '/', prefetch: { components: 'lazy', props: 'intent' }, }) ``` ## Hoisting When the `hoist` property is true, the route will be treated as a root route. This allows you to leverage the component nesting without having to use a nested URL. ```ts const parentRoute = createRoute({ name: 'parent', path: '/parent', }) const regularChildRoute = createRoute({ parent: parentRoute, name: 'parent.regular', path: '/regular', }) const hoistedExample = createRoute({ parent: parentRoute, name: 'parent.nested', path: '/nested', hoist: true, }) regularChildRoute.stringify() // ^ "/parent/regular" hoistedExample.string() // ^ "/nested" ``` ================================================ FILE: docs/index.md ================================================ --- # https://vitepress.dev/reference/default-theme-home-page layout: home hero: name: "Kitbag Router" text: "Type safe router for Vue.js" image: "/kitbag-logo.svg" tagline: Simple, extensible, and developer friendly routing for Vue.js actions: - theme: brand text: Get Started link: /introduction - theme: alt text: Documentation link: /core-concepts/routes - theme: alt stackblitz text: StackBlitz link: https://stackblitz.com/~/github.com/kitbagjs/router-preview features: - title: Type Safety details: No more magic strings when navigating, accessing params, etc. - title: Better Route Params details: Params are accessible on route by name, and converted to whatever type you need. - title: Support for Query details: Rich param support just like path params, including being considered in route matching. - title: Rejection Handling details: Configure app-wide rejection handling for 404, 401, whatever you need. --- ================================================ FILE: docs/introduction.md ================================================ # Introduction Kitbag Router introduces a fresh, developer-centric approach to routing in Vue.js applications. At the heart of Kitbag Router lies a commitment to enhancing the developer experience. First and foremost that means **type safety**, but also **better parameter experience**, **support for query**, **rejection handling**, simple intuitive syntax, and an extensible design written with modern Typescript. ## Why Does Vue Need a New Router? Vue router has served us well for the **many** years it's been around. However, there are some unfortunate aspects that all of us have just become accustomed to. | Vue Router | Kitbag Router | | -- | -- | | :x: Not type safe | :white_check_mark: Types safety **everywhere** | | :x: Weak params | :white_check_mark: Params can be **any type**, **reactive**, and **writable** | | :x: Only params in path | :white_check_mark: Params can be **anywhere** | | :x: No rejection handling | :white_check_mark: Built in rejection handling that's customizable | | :x: Not intuitive | :white_check_mark: No magic, url is assembled exactly how you'd expect | | :x: Old and bloated | :white_check_mark: 128kB, 1 dependency, written with modern Typescript | ## Type Safety You already know what routes exist, so why are we using magic strings and hoping it works out? With Kitbag Router, the routes that are available to you couldn't be clearer. If the routes change, Typescript will tell you the links that need to be updated. ## Better Route Params Adding dynamic parameters to your route is just as easy as you'd expect but infinitely more powerful. With Kitbag Router, your parameters can be expressed as `String`, `Number`, `Boolean`, `Date`, `JSON`, `RegExp`, or literally anything else. Parameters in the route will be expected when navigating (type safety!). This param type is enforced when matching routes, so routes can be differentiated by subtle changes in param types. ## Support for Query Defining a query on routes can control route matching, just like it does with the path. Better yet, with Kitbag Router you get the same support for params inside the query as you do the path! ## Rejection Handling Virtually every app that needs a router will eventually need to handle URLs without a match (404), routes protected by auth (401) but the solution is on you to figure out. With Kitbag Router you have this functionality out of the box. ================================================ FILE: docs/migrating-vue-router.md ================================================ # Migrating from vue-router :white_check_mark: Nested routes mapping :white_check_mark: Dynamic Routing :white_check_mark: Modular, component-based router configuration :white_check_mark: Route params, query, wildcards :white_check_mark: View transition effects powered by Vue.js' transition system :white_check_mark: Fine-grained navigation control :white_check_mark: Links with automatic active CSS classes :white_check_mark: HTML5 history mode or hash mode :x: Customizable Scroll Behavior :white_check_mark: Proper encoding for URLs ## Child Routes Child routes in vue-router have very different behavior depending on if the path starts with `/` or not. In Kitbag Router, the behavior is always the same, so add slashes where you want them and leave them off where you don't. ## Props Binding With vue-router you can bind all the route params to the component automatically with the `route.props` attribute. However, this is NOT type safe. Kitbag Router gives you a type safe way to bind props. If the component you assign to a route has required props, you'll get a Typescript error until you satisfy the props. ## Route Regex Kitbag Router support FULL regex pattern matching in both the path and query. The only caveat is that your regex must be encapsulated by a param. ```ts import { createRoute, withParams } from '@kitbag/router' const route = createRoute({ path: withParams('/[pattern]', { pattern: /\d{2}-\d{2}-\d{4}/g }) }) ``` The param will be used to verify any potential matches from the URL, regardless of if you actually use the param value stored on `route.params`. ## Repeatable Params Kitbag Router does support repeatable params like [vue-router](https://router.vuejs.org/guide/essentials/route-matching-syntax.html#Repeatable-params), but the syntax is different. By default Kitbag params capture everything including slashes, so a route that ends in a param will be considered a match. ```ts { name: 'repeated-params', path: '/[chapters]', component: ... }, ``` This param will expect at least (1) character past the slash to match, but will match - `/one` - `/one/two` - `/one/two/three` - etc Then to convert the captured value into an array, you'll need to define a [custom param](/core-concepts/params#custom-param-types). ```ts import { ParamGetSet } from '@kitbag/router' const stringArrayParam: ParamGetSet = { get: (value) => { return value.split('/') }, set: value => value.join('/'), } ``` Which is applied to the route with `withParams`. ```ts { name: 'repeated-params', path: withParams('/[chapters]', { chapters: stringArrayParam }),// [!code focus] component: ... }, ``` If you make the param optional, it will also match just a slash `/`, the param value would be an empty array `[]`. ## Redirect In order to setup redirects for your routes, you'll have to use route [hooks](/advanced-concepts/hooks). ```ts const newRoute = createRoute({ name: 'new-route', path: '/new', component: ... }) const oldRoute = createRoute({ name: 'old-route', path: '/old', context: [newRoute], }) oldRoute.onBeforeRouteEnter((to, { replace }) => { replace('new-route') }) ``` ## Alias If you need additional routes that ultimately result in another route being loaded, for now you'll need to define those routes and have them redirect with route [hooks](/advanced-concepts/hooks). ```ts const actualRoute = createRoute({ name: 'actual-route', path: '/new', component: ... }) const aliasRouteA = createRoute({ name: 'alias-route-a', path: '/alias-a', context: [actualRoute], }) const aliasRouteB = createRoute({ name: 'alias-route-b', path: '/alias-b', context: [actualRoute], }) aliasRouteA.onBeforeRouteEnter((to, { replace }) => { replace('actual-route') }) aliasRouteB.onBeforeRouteEnter((to, { replace }) => { replace('actual-route') }) ``` ================================================ FILE: docs/quick-start.md ================================================ # Quick Start ## Installation Install Kitbag Router with your favorite package manager ```bash # bun bun add @kitbag/router # yarn yarn add @kitbag/router # npm npm install @kitbag/router ``` ## Define Routes Routes are created individually using the [`createRoute`](/api/functions/createRoute) utility. Learn more about [defining routes](/core-concepts/routes). ```ts import { createRoute } from '@kitbag/router' const Home = { template: '
Home
' } const About = { template: '
About
' } const routes = [ createRoute({ name: 'home', path: '/', component: Home }), createRoute({ name: 'path', path: '/about', component: About }), ] as const ``` ::: info Type Safety Using `as const` when defining routes is important as it ensures the types are correctly inferred. ::: ## Create Router A router is created using the [`createRouter`](/api/functions/createRouter) utility and passing in the routes. ```ts import { createRouter } from '@kitbag/router' const router = createRouter(routes) ``` ## Vue Plugin Create a router instance and pass it to the app as a plugin ```ts {6} import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) app.use(router) app.mount('#app') ``` ## Type Safety Kitbag Router utilizes [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) to provide the internal types to match the actual router you're using. ```ts declare module '@kitbag/router' { interface Register { router: typeof router } } ``` This means then when you import a component, composition, or hook from `@kitbag/router` it will be correctly typed. Alternatively, you can create your own typed router assets by using the [`createRouterAssets`](/api/functions/createRouterAssets) utility. This approach is especially useful for projects that use multiple routers. ## RouterView Give your route components a place to be mounted ```html
``` This component can be mounted anywhere you want route components to be mounted. Nested routes can also have a nested `RouterView` which would be responsible for rendering any children that route may have. Read more about [nested routes](/core-concepts/routes#parent). ## RouterLink Use RouterLink for navigating between routes. ```html ``` ### Type Safety in RouterLink The `to` prop accepts a callback function or a [`Url`](/api/types/Url) string. When using a callback function, the router will provide a `resolve` function that is a type safe way to create link for your pre-defined routes. ================================================ FILE: eslint.config.js ================================================ import config from '@kitbag/eslint-config' /** @type {import('eslint').Linter.Config[]} */ export default [ ...config, { rules: { '@typescript-eslint/no-confusing-void-expression': ['off'], '@typescript-eslint/only-throw-error': ['off'], '@typescript-eslint/no-explicit-any': ['off'], }, }, { files: ['**/*.spec-d.ts'], rules: { '@typescript-eslint/no-unused-vars': ['off'], }, }, ] ================================================ FILE: package.json ================================================ { "name": "@kitbag/router", "private": false, "version": "0.24.1", "license": "MIT", "bugs": { "url": "https://github.com/kitbagjs/router/issues" }, "keywords": [ "typescript", "router", "vue", "vue-router", "types", "typed" ], "homepage": "https://github.com/kitbagjs/router#readme", "scripts": { "build": "vite build", "build:watch": "vite build --watch --minify=false", "dev": "vite build --watch --minify=false", "test": "vitest", "lint": "eslint ./src", "lint:fix": "eslint ./src --fix", "types": "vue-tsc --noEmit", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", "docs:generate": "vite build && typedoc && sleep 5 && node ./scripts/api.js" }, "type": "module", "files": [ "dist" ], "main": "./dist/kitbag-router.umd.cjs", "module": "./dist/kitbag-router.js", "types": "./dist/kitbag-router.d.ts", "exports": { ".": { "import": "./dist/kitbag-router.js", "require": "./dist/kitbag-router.umd.cjs" } }, "devDependencies": { "@kitbag/eslint-config": "1.0.2", "@vitejs/plugin-vue": "^6.0.5", "@vue/test-utils": "^2.4.8", "eslint": "^9.39.4", "globals": "^17.5.0", "happy-dom": "^20.9.0", "typedoc": "^0.28.19", "typedoc-plugin-markdown": "^4.11.0", "typedoc-vitepress-theme": "^1.1.2", "typescript": "^6.0.2", "valibot": "^1.0.0", "vite": "^8.0.3", "vite-plugin-dts": "^4.5.4", "vitepress": "^1.6.4", "vitest": "^4.1.5", "vue-tsc": "^3.2.6", "zod": "4.0.0" }, "peerDependencies": { "vue": "^3.5.0", "zod": "4.0.0" }, "peerDependenciesMeta": { "zod": { "optional": true } }, "dependencies": { "@standard-schema/spec": "^1.0.0", "@vue/devtools-api": "^8.1.1" } } ================================================ FILE: scripts/api.js ================================================ import path from 'path' import fs from 'fs/promises' async function renameTypeAliasesFolderToTypes() { try { const oldPath = path.join(process.cwd(), 'docs', 'api', 'type-aliases') const newPath = path.join(process.cwd(), 'docs', 'api', 'types') // Check if old directory exists await fs.access(oldPath) // Rename the directory await fs.rename(oldPath, newPath) console.log('Successfully renamed type-aliases folder to types') } catch (error) { if (error.code === 'ENOENT') { console.error('Error: The type-aliases directory does not exist') } else { console.error('Error renaming folder:', error) } process.exit(1) } } async function updateReferences(searchString, replaceString) { try { const apiDir = path.join(process.cwd(), 'docs', 'api') const entries = await fs.readdir(apiDir, { withFileTypes: true, recursive: true }) for (const entry of entries) { if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.json'))) { const fullPath = path.join(entry.path || '', entry.name) const content = await fs.readFile(fullPath, 'utf8') const updatedContent = content.replace(new RegExp(searchString, 'g'), replaceString) if (content !== updatedContent) { await fs.writeFile(fullPath, updatedContent, 'utf8') console.log(`Updated references in: ${fullPath}`) } } } console.log(`Successfully updated all references from ${searchString} to ${replaceString}`) } catch (error) { console.error('Error updating references:', error) throw error } } async function organizeFilesByGroup() { try { const apiDir = path.join(process.cwd(), 'docs', 'api') const entries = await fs.readdir(apiDir, { withFileTypes: true, recursive: true }) const changes = new Map() // Track old paths to new paths // First pass: collect all the moves we need to make for (const entry of entries) { if (entry.isFile() && entry.name.endsWith('.md')) { const fullPath = path.join(entry.path || '', entry.name) const content = await fs.readFile(fullPath, 'utf8') // Extract group from H1 heading const match = content.match(/^# ([^:]+):/m) if (match) { const group = match[1].trim() const folderName = group.toLowerCase().replace(/\s+/g, '-') const targetDir = path.join(apiDir, folderName) // Create directory if it doesn't exist try { await fs.access(targetDir) } catch { await fs.mkdir(targetDir) console.log(`Created new directory: ${targetDir}`) } // Check if file needs to be moved const currentDir = path.dirname(fullPath) if (currentDir !== targetDir) { const oldPath = path.relative(apiDir, fullPath) const newPath = path.join(folderName, entry.name) changes.set(oldPath, newPath) } } } } // Second pass: perform the moves for (const [oldPath, newPath] of changes) { const sourcePath = path.join(apiDir, oldPath) const targetPath = path.join(apiDir, newPath) await fs.rename(sourcePath, targetPath) console.log(`Moved ${oldPath} to ${newPath}`) } // Third pass: update all references for each move for (const [oldPath, newPath] of changes) { await updateReferences(oldPath, newPath) } console.log('Successfully organized files by their groups and updated all references') } catch (error) { console.error('Error organizing files:', error) throw error } } await renameTypeAliasesFolderToTypes() await updateReferences('type-aliases', 'types') await updateReferences('Type Aliases', 'Types') await organizeFilesByGroup() ================================================ FILE: src/components/echo.ts ================================================ import { defineComponent } from 'vue' export default defineComponent((props) => { return () => props.value }, { props: { value: { type: String, required: true, }, }, }) ================================================ FILE: src/components/helloWorld.ts ================================================ import { defineComponent } from 'vue' export default defineComponent({ name: 'HelloWorld', template: 'hello world', expose: [], }) ================================================ FILE: src/components/rejection.ts ================================================ import { Component, defineComponent, h } from 'vue' export function genericRejection(type: string): Component { return defineComponent(() => { return () => h('h1', type) }, { name: type, props: [], }) } ================================================ FILE: src/components/routerLink.browser.spec.ts ================================================ import { flushPromises, mount } from '@vue/test-utils' import { describe, expect, test, vi } from 'vitest' import { defineAsyncComponent, h, nextTick, ref } from 'vue' import echo from '@/components/echo' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { PrefetchConfig } from '@/types/prefetch' import { getPrefetchConfigValue } from '@/utilities/prefetch' import { component } from '@/utilities/testHelpers' import { visibilityObserverKey } from '@/compositions/useVisibilityObserver' import { VisibilityObserver } from '@/services/createVisibilityObserver' import { UrlString } from '@/types/urlString' import { RouterPushOptions } from '@/types/routerPush' import { RouterLink } from '@/main' test('renders an anchor tag with the correct href and slot content', () => { const path = '/path/[paramName]' const paramValue = 'ABC' const content = 'hello world' const href = new URL(path.replace('[paramName]', paramValue), window.location.origin) const route = createRoute({ name: 'parent', path, component, }) const router = createRouter([route], { initialUrl: path, }) const wrapper = mount(RouterLink, { props: { to: (resolve) => resolve('parent', { paramName: paramValue }), }, slots: { default: content, }, global: { plugins: [router], }, }) const anchor = wrapper.find('a') const element = anchor.element as HTMLAnchorElement expect(element).toBeInstanceOf(HTMLAnchorElement) expect(element.href).toBe(href.toString()) expect(element.innerHTML).toBe(content) }) test('calls router.push with url and push options from props', async () => { const propOptions: RouterPushOptions = { replace: true, query: { foo: 'bar', }, hash: 'hash', state: { zoo: 'jar', }, } const router = createRouter([ createRoute({ name: 'routeA', path: '/routeA', component: { render: () => h(RouterLink, { to: (resolve) => resolve('routeB'), ...propOptions }) }, }), createRoute({ name: 'routeB', path: '/routeB', component, }), ], { initialUrl: '/routeA', }) await router.start() const spy = vi.spyOn(router, 'push') const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) wrapper.find('a').trigger('click') const [, pushOptions] = spy.mock.lastCall ?? [] expect(pushOptions).toMatchObject({ replace: true, query: new URLSearchParams({ foo: 'bar', }), hash: 'hash', state: { zoo: 'jar', }, }) }) test('calls router.push with url and push options from resolve callback', async () => { const resolveOptions: RouterPushOptions = { query: { foo: 'bar', }, hash: 'hash', state: { zoo: 'jar', }, } const router = createRouter([ createRoute({ name: 'routeA', path: '/routeA', component: { render: () => h(RouterLink, { to: (resolve) => resolve('routeB', {}, resolveOptions) }) }, }), createRoute({ name: 'routeB', path: '/routeB', state: { zoo: String, }, component, }), ], { initialUrl: '/routeA', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) wrapper.find('a').trigger('click') await flushPromises() expect(router.route.query.toString()).toBe('foo=bar') expect(router.route.hash).toBe('#hash') expect(router.route.state).toMatchObject({ zoo: 'jar' }) }) test('given push options from both resolve callback and props, combines query and state, overrides hash and replace', async () => { const propOptions: RouterPushOptions = { query: { prop: 'foo', }, hash: 'propHash', state: { prop: 'jar', }, } const resolveOptions: RouterPushOptions = { query: { resolve: 'foo', }, hash: 'resolveHash', state: { resolve: 'jar', }, } const router = createRouter([ createRoute({ name: 'routeA', path: '/routeA', component: { render: () => h(RouterLink, { to: (resolve) => resolve('routeB', {}, resolveOptions), ...propOptions }) }, }), createRoute({ name: 'routeB', path: '/routeB', state: { prop: String, resolve: String, }, component, }), ], { initialUrl: '/routeA', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) wrapper.find('a').trigger('click') await flushPromises() expect(router.route.query.toString()).toBe('resolve=foo&prop=foo') expect(router.route.hash).toBe('#propHash') expect(router.route.state).toMatchObject({ resolve: 'jar', prop: 'jar', }) }) test.each([ { name: 'metaKey', triggerOptions: { metaKey: true } }, { name: 'ctrlKey', triggerOptions: { ctrlKey: true } }, { name: 'shiftKey', triggerOptions: { shiftKey: true } }, { name: 'altKey', triggerOptions: { altKey: true } }, ])('does not call router.push when $name is held during click', async ({ triggerOptions }) => { const router = createRouter([ createRoute({ name: 'routeA', path: '/routeA', component: { render: () => h(RouterLink, { to: (resolve) => resolve('routeB') }) }, }), createRoute({ name: 'routeB', path: '/routeB', component, }), ], { initialUrl: '/routeA', }) await router.start() const spy = vi.spyOn(router, 'push') const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) wrapper.find('a').trigger('click', triggerOptions) expect(spy).not.toHaveBeenCalled() }) test('does not call router.push when middle mouse button is clicked', async () => { const router = createRouter([ createRoute({ name: 'routeA', path: '/routeA', component: { render: () => h(RouterLink, { to: (resolve) => resolve('routeB') }) }, }), createRoute({ name: 'routeB', path: '/routeB', component, }), ], { initialUrl: '/routeA', }) await router.start() const spy = vi.spyOn(router, 'push') const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) wrapper.find('a').trigger('click', { button: 1 }) expect(spy).not.toHaveBeenCalled() }) test('does not call router.push when anchor has target="_blank"', async () => { const router = createRouter([ createRoute({ name: 'routeA', path: '/routeA', component: { render: () => h(RouterLink, { to: (resolve) => resolve('routeB'), target: '_blank' }) }, }), createRoute({ name: 'routeB', path: '/routeB', component, }), ], { initialUrl: '/routeA', }) await router.start() const spy = vi.spyOn(router, 'push') const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) wrapper.find('a').trigger('click') expect(spy).not.toHaveBeenCalled() }) test('to prop as Url renders and routes correctly', async () => { const route = createRoute({ name: 'route', path: '/route', component, }) const href = new URL('/route', window.location.origin) const router = createRouter([route], { initialUrl: '/route', }) const wrapper = mount(RouterLink, { props: { to: '/route', }, slots: { default: 'route', }, global: { plugins: [router], }, }) const anchor = wrapper.find('a') const element = anchor.element as HTMLAnchorElement expect(element).toBeInstanceOf(HTMLAnchorElement) expect(element.href).toBe(href.toString()) expect(element.innerHTML).toBe('route') wrapper.find('a').trigger('click') await flushPromises() expect(router.route.name).toBe('route') }) test.each<{ to: UrlString, match: boolean, exactMatch: boolean }>([ { to: '/parent-route', match: true, exactMatch: false }, { to: '/parent-route/child-route', match: true, exactMatch: true }, { to: '/other', match: false, exactMatch: false }, ])('isMatch and isExactMatch classes and slot props works as expected', async ({ to, match, exactMatch }) => { const parentRoute = createRoute({ name: 'parent-route', path: '/parent-route', }) const childRoute = createRoute({ parent: parentRoute, name: 'child-route', path: '/child-route', component, }) const otherRoute = createRoute({ name: 'other', path: '/other', component, }) const router = createRouter([parentRoute, childRoute, otherRoute], { initialUrl: '/parent-route/child-route', }) const wrapper = mount(RouterLink, { props: { to, }, slots: { default: '{{ params.isMatch }} {{ params.isExactMatch }}', }, global: { plugins: [router], }, }) await router.start() const anchor = wrapper.find('a') expect(anchor.text()).toBe(`${match} ${exactMatch}`) if (match) { expect(anchor.classes()).toContain('router-link--match') } else { expect(anchor.classes()).not.toContain('router-link--match') } if (exactMatch) { expect(anchor.classes()).toContain('router-link--exact-match') } else { expect(anchor.classes()).not.toContain('router-link--exact-match') } }) test('isMatch correctly matches parent when sibling has the same url', async () => { const parentRoute = createRoute({ name: 'parent', path: '/parent', }) const siblingRoute = createRoute({ parent: parentRoute, name: 'sibling', component, }) const childRoute = createRoute({ parent: parentRoute, name: 'child', path: '/child', component: () => h(RouterLink, { to: (resolve) => resolve('parent') }, () => 'parent'), }) const router = createRouter([parentRoute, siblingRoute, childRoute], { initialUrl: '/parent/child', }) const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.start() const link = wrapper.find('a') expect(link.classes()).toContain('router-link--match') expect(link.classes()).not.toContain('router-link--exact-match') }) test.each<{ to: UrlString, active: boolean, exactActive: boolean }>([ { to: '/parent-route', active: true, exactActive: false }, { to: '/parent-route/child-route/pass', active: true, exactActive: true }, { to: '/parent-route/child-route/fail', active: false, exactActive: false }, { to: '/other', active: false, exactActive: false }, ])('isActive and isExactActive classes and slot props work as expected', async ({ to, active, exactActive }) => { const parentRoute = createRoute({ name: 'parent-route', path: '/parent-route', }) const childRoute = createRoute({ parent: parentRoute, name: 'child-route', path: '/child-route/[param]', component, }) const otherRoute = createRoute({ name: 'other', path: '/other', component, }) const router = createRouter([parentRoute, childRoute, otherRoute], { initialUrl: '/parent-route/child-route/pass', }) const wrapper = mount(RouterLink, { props: { to, }, slots: { default: '{{ params.isActive }} {{ params.isExactActive }}', }, global: { plugins: [router], }, }) await router.start() const anchor = wrapper.find('a') expect(anchor.text()).toBe(`${active} ${exactActive}`) if (active) { expect(anchor.classes()).toContain('router-link--active') } else { expect(anchor.classes()).not.toContain('router-link--active') } if (exactActive) { expect(anchor.classes()).toContain('router-link--exact-active') } else { expect(anchor.classes()).not.toContain('router-link--exact-active') } }) test.each([ [true], [false], ])('isExternal slot prop works as expected', async (isExternal) => { const parentRoute = createRoute({ name: 'parent-route', path: '/parent-route', }) const childRoute = createRoute({ parent: parentRoute, name: 'child-route', path: '/child-route', component, }) const router = createRouter([parentRoute, childRoute], { initialUrl: '/parent-route', }) const wrapper = mount(RouterLink, { props: { to: isExternal ? 'https://vuejs.org/' : '/parent-route', }, slots: { default: '{{ params.isExternal }}', }, global: { plugins: [router], }, }) await router.start() const anchor = wrapper.find('a') expect(anchor.text()).toBe(isExternal.toString()) }) describe('prefetch components', () => { test.each([ undefined, true, false, 'eager', 'lazy', { components: true }, { components: false }, { components: 'eager' }, { components: 'lazy' }, ])('prefetch components respects router config when prefetch is %s', async (prefetch) => { let loaded = undefined const route = createRoute({ name: 'route', path: '/route', component: defineAsyncComponent(() => { return new Promise((resolve) => { loaded = true resolve({ default: { template: 'foo' } }) }) }), }) const router = createRouter([route], { initialUrl: '/', prefetch, }) mount(RouterLink, { props: { to: '/route', }, global: { plugins: [router], }, }) await flushPromises() const value = getPrefetchConfigValue(prefetch, 'components') if (value === 'eager') { expect(loaded).toBe(true) } else { expect(loaded).toBeUndefined() } }) test.each([ undefined, true, false, 'eager', 'lazy', { components: true }, { components: false }, { components: 'eager' }, { components: 'lazy' }, ])('prefetch components respects route config when prefetch is %s', async (prefetch) => { let loaded = undefined const route = createRoute({ name: 'route', path: '/route', prefetch, component: defineAsyncComponent(() => { return new Promise((resolve) => { loaded = true resolve({ default: { template: 'foo' } }) }) }), }) const router = createRouter([route], { initialUrl: '/', }) mount(RouterLink, { props: { to: '/route', }, global: { plugins: [router], }, }) await flushPromises() const value = getPrefetchConfigValue(prefetch, 'components') if (value === 'eager') { expect(loaded).toBe(true) } else { expect(loaded).toBeUndefined() } }) test.each([ undefined, true, false, 'eager', 'lazy', { components: true }, { components: false }, { components: 'eager' }, { components: 'lazy' }, ])('prefetch components respects link config when prefetch is %s', async (prefetch) => { let loaded = undefined const route = createRoute({ name: 'route', path: '/route', component: defineAsyncComponent(() => { return new Promise((resolve) => { loaded = true resolve({ default: { template: 'foo' } }) }) }), }) const router = createRouter([route], { initialUrl: '/', }) mount(RouterLink, { props: { to: '/route', prefetch, }, global: { plugins: [router], }, }) await flushPromises() const value = getPrefetchConfigValue(prefetch, 'components') if (value === 'eager') { expect(loaded).toBe(true) } else { expect(loaded).toBeUndefined() } }) }) describe('prefetch props', () => { test.each([ undefined, true, false, 'eager', 'lazy', { props: true }, { props: false }, { props: 'eager' }, { props: 'lazy' }, ])('prefetch props respects router config when prefetch is %s', (prefetch) => { const callback = vi.fn() const route = createRoute({ name: 'route', path: '/route', component: echo, }, callback) const router = createRouter([route], { initialUrl: '/', prefetch, }) mount(RouterLink, { props: { to: '/route', }, global: { plugins: [router], }, }) const value = getPrefetchConfigValue(prefetch, 'props') if (value === 'eager') { expect(callback).toHaveBeenCalledOnce() } else { expect(callback).not.toHaveBeenCalled() } }) test.each([ undefined, true, false, 'eager', 'lazy', { props: true }, { props: false }, { props: 'eager' }, { props: 'lazy' }, ])('prefetch props respects route config when prefetch is %s', (prefetch) => { const callback = vi.fn() const route = createRoute({ name: 'route', path: '/route', component: echo, prefetch, }, callback) const router = createRouter([route], { initialUrl: '/', }) mount(RouterLink, { props: { to: '/route', }, global: { plugins: [router], }, }) const value = getPrefetchConfigValue(prefetch, 'props') if (value === 'eager') { expect(callback).toHaveBeenCalledOnce() } else { expect(callback).not.toHaveBeenCalled() } }) test.each([ undefined, true, false, 'eager', 'lazy', { props: true }, { props: false }, { props: 'eager' }, { props: 'lazy' }, ])('prefetch props respects link config when prefetch is %s', (prefetch) => { const callback = vi.fn() const route = createRoute({ name: 'route', path: '/route', component: echo, }, callback) const router = createRouter([route], { initialUrl: '/', }) mount(RouterLink, { props: { to: '/route', prefetch, }, global: { plugins: [router], }, }) const value = getPrefetchConfigValue(prefetch, 'props') if (value === 'eager') { expect(callback).toHaveBeenCalledOnce() } else { expect(callback).not.toHaveBeenCalled() } }) test('prefetch props are passed to route component', async () => { const value = 'hello world' const props = vi.fn(() => Promise.resolve({ value, })) const home = createRoute({ name: 'home', path: '/', component: () => h(RouterLink, { to: (resolve) => resolve('echo') }), }) const route = createRoute({ name: 'echo', path: '/echo', component: echo, prefetch: { props: 'eager' }, }, props) const router = createRouter([home, route], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(props).toHaveBeenCalledOnce() wrapper.find('a').trigger('click') await flushPromises() expect(router.route.name).toBe('echo') expect(props).toHaveBeenCalledOnce() expect(wrapper.text()).toBe(value) }) test('parent routes that should not prefetch props are not prefetched', async () => { const parentProps = vi.fn() const childProps = vi.fn() const home = createRoute({ name: 'home', path: '/', component: () => h(RouterLink, { to: (resolve) => resolve('child') }), }) const parent = createRoute({ name: 'parent', path: '/parent', component: echo, prefetch: { props: false }, }, parentProps) const child = createRoute({ parent, name: 'child', path: '/child', component: echo, prefetch: { props: 'eager' }, }, childProps) const router = createRouter([home, child], { initialUrl: '/', }) await router.start() const root = { template: '', } mount(root, { global: { plugins: [router], }, }) expect(childProps).toHaveBeenCalledOnce() expect(parentProps).not.toHaveBeenCalledOnce() }) test('props are not prefetched until link is visible when prefetch is lazy', async () => { const callback = vi.fn() const routeA = createRoute({ name: 'routeA', path: '/routeA', component: () => h(RouterLink, { to: (resolve) => resolve('routeB') }), }) const routeB = createRoute({ name: 'routeB', path: '/routeB', component: echo, prefetch: { props: 'lazy' }, }, callback) const router = createRouter([routeA, routeB], { initialUrl: '/routeA', }) await router.start() const visible = ref(false) const root = { template: '', provide: { [visibilityObserverKey]: { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), isElementVisible: () => visible.value, } satisfies VisibilityObserver, }, } mount(root, { global: { plugins: [router], }, }) expect(callback).not.toHaveBeenCalled() visible.value = true await nextTick() expect(callback).toHaveBeenCalled() }) test('components are not prefetched until link is visible when prefetch is lazy', async () => { let loaded = false const routeA = createRoute({ name: 'routeA', path: '/routeA', component: () => h(RouterLink, { to: (resolve) => resolve('routeB') }), }) const routeB = createRoute({ name: 'routeB', path: '/routeB', prefetch: { components: 'lazy' }, component: defineAsyncComponent(() => { return new Promise((resolve) => { loaded = true resolve({ default: { template: 'foo' } }) }) }), }) const router = createRouter([routeA, routeB], { initialUrl: '/routeA', }) await router.start() const visible = ref(false) const root = { template: '', provide: { [visibilityObserverKey]: { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), isElementVisible: () => visible.value, } satisfies VisibilityObserver, }, } mount(root, { global: { plugins: [router], }, }) expect(loaded).toBe(false) visible.value = true await nextTick() expect(loaded).toBe(true) }) test('props are not prefetched until link is focused when prefetch is intent', async () => { const callback = vi.fn() const routeA = createRoute({ name: 'routeA', path: '/routeA', component: () => h(RouterLink, { to: (resolve) => resolve('routeB') }), }) const routeB = createRoute({ name: 'routeB', path: '/routeB', component: echo, prefetch: { props: 'intent' }, }, callback) const router = createRouter([routeA, routeB], { initialUrl: '/routeA', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await nextTick() expect(callback).not.toHaveBeenCalled() const link = wrapper.find('a') link.trigger('focusin') await nextTick() expect(callback).toHaveBeenCalled() }) test('components are not prefetched until link is focused when prefetch is intent', async () => { let loaded = false const routeA = createRoute({ name: 'routeA', path: '/routeA', component: () => h(RouterLink, { to: (resolve) => resolve('routeB') }, () => 'routeB'), }) const routeB = createRoute({ name: 'routeB', path: '/routeB', prefetch: { components: 'intent' }, component: defineAsyncComponent(() => { return new Promise((resolve) => { loaded = true resolve({ template: 'bar' }) }) }), }) const router = createRouter([routeA, routeB], { initialUrl: '/routeA', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await nextTick() expect(loaded).toBe(false) wrapper.find('a').trigger('focusin') await nextTick() expect(loaded).toBe(true) }) }) ================================================ FILE: src/components/routerLink.ts ================================================ import { isUrlString, UrlString } from '@/types/urlString' import { ResolvedRoute } from '@/types/resolved' import { computed, defineComponent, EmitsOptions, h, InjectionKey, SetupContext, SlotsType, VNode } from 'vue' import { createUseRouter } from '@/compositions/useRouter' import { Router } from '@/types/router' import { createUseLink } from '@/compositions/useLink' import { RouterLinkProps, ToCallback } from '@/types/routerLink' type RouterLinkSlots = { default?: (props: { route: ResolvedRoute | undefined, isMatch: boolean, isExactMatch: boolean, isActive: boolean, isExactActive: boolean, isExternal: boolean, }) => VNode[], } // Inferring the return type of the component is more accurate than defining it manually // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createRouterLink(routerKey: InjectionKey) { const useRouter = createUseRouter(routerKey) const useLink = createUseLink(routerKey) return defineComponent((props: RouterLinkProps, context: SetupContext>) => { const router = useRouter() const route = computed(() => getResolvedRoute(props.to)) const href = computed(() => getHref(props.to)) const targetSelf = computed(() => !props.target || props.target === '_self') const options = computed(() => { const { to, ...options } = props return options }) const { element, isMatch, isExactMatch, isActive, isExactActive, isExternal, push } = useLink(() => { if (typeof props.to === 'function') { return props.to(router.resolve) } return props.to }, options) const classes = computed(() => ({ 'router-link--match': isMatch.value, 'router-link--exact-match': isExactMatch.value, 'router-link--active': isActive.value, 'router-link--exact-active': isExactActive.value, })) function getResolvedRoute(to: UrlString | ResolvedRoute | ToCallback | undefined): ResolvedRoute | undefined { if (typeof to === 'function') { const callbackValue = to(router.resolve) return getResolvedRoute(callbackValue) } return isUrlString(to) ? router.find(to) : to } function getHref(to: UrlString | ResolvedRoute | ToCallback | undefined): UrlString | undefined { if (typeof to === 'function') { const callbackValue = to(router.resolve) return getHref(callbackValue) } if (isUrlString(to)) { return to } return to?.href } function shouldAllowDefault(event: MouseEvent): boolean { return event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || !targetSelf.value } function onClick(event: MouseEvent): void { if (shouldAllowDefault(event)) { return } event.preventDefault() push() } return () => { return h('a', { href: href.value, target: props.target, class: ['router-link', classes.value], ref: element, onClick, }, context.slots.default?.({ route: route.value, isMatch: isMatch.value, isExactMatch: isExactMatch.value, isActive: isActive.value, isExactActive: isExactActive.value, isExternal: isExternal.value, }), ) } }, { name: 'RouterLink', // The prop types are defined above. Vue requires manually defining the prop names themselves here to distinguish from attrs // eslint-disable-next-line vue/require-prop-types props: ['to', 'prefetch', 'query', 'hash', 'replace', 'state', 'target'], }) } ================================================ FILE: src/components/routerView.browser.spec.ts ================================================ import { mount, flushPromises } from '@vue/test-utils' import { expect, test } from 'vitest' import { defineAsyncComponent, h } from 'vue' import echo from '@/components/echo' import helloWorld from '@/components/helloWorld' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { isWithComponent } from '@/types/createRouteOptions' import { component, routes } from '@/utilities/testHelpers' import { RouterLink } from '@/main' import { createRejection } from '@/services/createRejection' test('renders component for initial route', async () => { const route = createRoute({ name: 'foo', path: '/', component: { template: 'hello world' }, }) const router = createRouter([route], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(wrapper.html()).toBe('hello world') }) test('renders components for initial route', async () => { const parentRoute = createRoute({ name: 'parent', path: '/parent', }) const childRoute = createRoute({ parent: parentRoute, name: 'child', path: '/child', component: { template: 'Child' }, }) const router = createRouter([parentRoute, childRoute], { initialUrl: '/parent/child', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(wrapper.html()).toBe('Child') }) test('does not render component when router is not started', async () => { const route = createRoute({ name: 'foo', path: '/', component: { template: 'hello world' }, }) const router = createRouter([route], { initialUrl: '/', }) const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(wrapper.text()).toBe('') await router.start() expect(wrapper.text()).toBe('hello world') }) test('updates components when route changes', async () => { const routes = [ createRoute({ name: 'foo', path: '/foo', component: { template: 'Foo' }, }), createRoute({ name: 'bar', path: '/bar', component: { template: 'Bar' }, }), createRoute({ name: 'zoo', path: '/zoo', component: { template: 'Zoo' }, }), ] as const const router = createRouter(routes, { initialUrl: '/foo', }) const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.start() expect(wrapper.html()).toBe('Foo') await router.push('/bar') expect(wrapper.html()).toBe('Bar') await router.push('/zoo') expect(wrapper.html()).toBe('Zoo') await router.push('/foo') expect(wrapper.html()).toBe('Foo') }) test('resolves async components', async () => { const route = createRoute({ name: 'async', path: '/', component: defineAsyncComponent(() => import('./helloWorld')), }) const router = createRouter([route], { initialUrl: '/', }) const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.start() await flushPromises() await flushPromises() await flushPromises() expect(wrapper.html()).toBe(helloWorld.template) }) test('Renders the genericRejection component when the initialUrl does not match', async () => { const router = createRouter([], { initialUrl: '/does-not-exist', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(wrapper.text()).toBe('NotFound') }) test('Renders custom genericRejection component when the initialUrl does not match', async () => { const NotFound = { template: 'Custom Not Found' } const notFoundRejection = createRejection({ type: 'NotFound', component: NotFound, }) const router = createRouter(routes, { initialUrl: '/does-not-exist', rejections: [notFoundRejection], }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) if (!isWithComponent(router.route.matched)) { throw new Error('Matched route does not have a single component') } const route = mount(router.route.matched.component) expect(wrapper.text()).toBe(NotFound.template) expect(route.text()).toBe(NotFound.template) }) test('Renders the NotFound component when the router.push does not match', async () => { const route = createRoute({ name: 'foo', path: '/', component: { template: 'hello world' }, }) const router = createRouter([route], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.push('/does-not-exist') expect(wrapper.text()).toBe('NotFound') }) test('Renders the route component when the router.push does match after a rejection', async () => { const route = createRoute({ name: 'foo', path: '/', component: { template: 'hello world' }, }) const router = createRouter([route], { initialUrl: '/does-not-exist', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(wrapper.text()).toBe('NotFound') await router.push('/') expect(wrapper.text()).toBe('hello world') }) test('Renders the multiple components when using named route views', async () => { const route = createRoute({ name: 'foo', path: '/', components: { default: { template: '_default_' }, one: { template: '_one_' }, two: { template: '_two_' }, }, }) const router = createRouter([route], { initialUrl: '/', }) await router.start() const root = { template: ` `, } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(wrapper.text()).toBe('_one__default__two_') }) test('Binds props and attrs from route', async () => { const routeA = createRoute({ name: 'routeA', path: '/routeA/[param]', component: echo, }, (route) => ({ value: route.params.param })) const routeB = createRoute({ name: 'routeB', path: '/routeB/[param]', component: echo, }, (route) => ({ value: route.params.param })) const router = createRouter([routeA, routeB], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.push('/routeA/hello') expect(wrapper.html()).toBe('hello') await router.push('/routeB/world') expect(wrapper.html()).toBe('world') }) test('Updates props and attrs when route params change', async () => { const syncProps = createRoute({ name: 'sync', path: '/sync/[param]', component: echo, }, (route) => ({ value: route.params.param })) const asyncProps = createRoute({ name: 'async', path: '/async/[param]', component: echo, }, async (route) => ({ value: route.params.param })) const router = createRouter([syncProps, asyncProps], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.push('sync', { param: 'foo' }) expect(wrapper.html()).toBe('foo') await router.push('sync', { param: 'bar' }) expect(wrapper.html()).toBe('bar') await router.push('async', { param: 'async-foo' }) await flushPromises() expect(wrapper.html()).toBe('async-foo') await router.push('async', { param: 'async-bar' }) await flushPromises() expect(wrapper.html()).toBe('async-bar') }) test('Props from route can trigger push', async () => { const routeA = createRoute({ name: 'routeA', path: '/routeA', component: echo, }, (__, context) => { throw context.push('/routeB') }) const routeB = createRoute({ name: 'routeB', path: '/routeB', component: echo, }, () => ({ value: 'routeB', })) const router = createRouter([routeA, routeB], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.push('/routeA') await flushPromises() expect(wrapper.html()).toBe('routeB') }) test('Props from route can trigger reject', async () => { const routeA = createRoute({ name: 'routeA', path: '/routeA', component: echo, }, (__, context) => { throw context.reject('NotFound') }) const router = createRouter([routeA], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.push('/routeA') await flushPromises() expect(wrapper.html()).toBe('

NotFound

') }) test('prefetched props trigger push when navigation is initiated', async () => { const routeA = createRoute({ name: 'routeA', path: '/routeA', component: { render: () => h(RouterLink, { to: (resolve) => resolve('routeB') }, () => 'routeB') }, }) const routeB = createRoute({ name: 'routeB', path: '/routeB', component: echo, prefetch: { props: true }, }, (__, { push }) => { throw push('/routeC') }) const routeC = createRoute({ name: 'routeC', path: '/routeC', component: echo, }, () => ({ value: 'routeC', })) const router = createRouter([routeA, routeB, routeC], { initialUrl: '/routeA', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(wrapper.text()).toBe('routeB') wrapper.find('a').trigger('click') await flushPromises() expect(wrapper.text()).toBe('routeC') }) test('prefetched async props trigger push when navigation is initiated', async () => { const routeA = createRoute({ name: 'routeA', path: '/routeA', component: { render: () => h(RouterLink, { to: (resolve) => resolve('routeB') }, () => 'routeB') }, }) const routeB = createRoute({ name: 'routeB', path: '/routeB', component, prefetch: { props: true }, }, (__, { push }) => { throw push('/routeC') }) const routeC = createRoute({ name: 'routeC', path: '/routeC', component: echo, }, () => ({ value: 'routeC', })) const router = createRouter([routeA, routeB, routeC], { initialUrl: '/routeA', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) expect(wrapper.text()).toBe('routeB') wrapper.find('a').trigger('click') await flushPromises() expect(wrapper.text()).toBe('routeC') }) test('Renders correct component when using default slot', async () => { const myRejection = createRejection({ type: 'myRejection', component: { template: 'My Rejection', }, }) const foo = createRoute({ name: 'foo', path: '/foo', component: { template: 'Foo', }, }) const bar = createRoute({ name: 'bar', path: '/bar', component: { template: 'Bar', }, }) const router = createRouter([foo, bar], { initialUrl: '/foo', rejections: [myRejection], }) await router.start() const wrapper = mount({ template: ` `, }, { global: { plugins: [router], }, }) expect(wrapper.text()).toBe('Foo') await router.push('bar') expect(wrapper.text()).toBe('Bar') router.reject('myRejection') await flushPromises() expect(wrapper.text()).toBe('My Rejection') }) test('Renders the rejection component when the rejection is not registered on the router', async () => { const rejectionText = 'Rejection content to render' const myRejection = createRejection({ type: 'myRejection', component: { template: rejectionText, }, }) const route = createRoute({ name: 'foo', path: '/', context: [myRejection], component: { template: 'Should not be rendered' }, }) route.onBeforeRouteEnter((_to, { reject }) => { throw reject('myRejection') }) const router = createRouter([route], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await flushPromises() expect(wrapper.text()).toBe(rejectionText) }) ================================================ FILE: src/components/routerView.spec.ts ================================================ import { createSSRApp } from 'vue' import { describe, it, expect } from 'vitest' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { renderToString } from 'vue/server-renderer' describe('SSR', () => { it('should render the route', async () => { const route = createRoute({ name: 'foo', path: '/', component: { template: 'hello world' }, }) const router = createRouter([route], { initialUrl: '/', }) const app = createSSRApp({ template: '', }) app.use(router) const html = await renderToString(app) expect(html).toMatchInlineSnapshot('"hello world"') }) }) ================================================ FILE: src/components/routerView.ts ================================================ import { createUseComponentsStore } from '@/compositions/useComponentsStore' import { createUseRejection } from '@/compositions/useRejection' import { createUseRoute } from '@/compositions/useRoute' import { createUseRouter } from '@/compositions/useRouter' import { createUseRouterDepth } from '@/compositions/useRouterDepth' import { isRejection, RouterRejection } from '@/types/rejection' import { RouterRoute } from '@/types/routerRoute' import { Router } from '@/types/router' import { Component, computed, defineComponent, EmitsOptions, h, InjectionKey, onServerPrefetch, SetupContext, SlotsType, UnwrapRef, VNode } from 'vue' export type RouterViewProps = { name?: string, } type RouterViewSlots = { default?: (props: { route: RouterRoute, component: Component | null, rejection: UnwrapRef, }) => VNode, } // Inferring the return type of the component is more accurate than defining it manually // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createRouterView(routerKey: InjectionKey) { const useRoute = createUseRoute(routerKey) const useRouter = createUseRouter(routerKey) const useRejection = createUseRejection(routerKey) const useRouterDepth = createUseRouterDepth(routerKey) const useComponentsStore = createUseComponentsStore(routerKey) return defineComponent((props: RouterViewProps, context: SetupContext>) => { const route = useRoute() const router = useRouter() const rejection = useRejection() const depth = useRouterDepth({ increment: true }) onServerPrefetch(async () => { await router.start() }) const { getRouteComponents } = useComponentsStore() const component = computed(() => { if (!router.started.value) { return null } if (isRejection(rejection.value) && !!rejection.value.route.matched.component) { return rejection.value.route.matched.component } const match = route.matches.at(depth) if (!match) { return null } const components = getRouteComponents(match) const name = props.name ?? 'default' return components[name] }) return () => { if (context.slots.default) { return context.slots.default({ route, component: component.value, rejection: rejection.value, }) } if (!component.value) { return null } // Don't use a key - let Vue handle component reuse/remounting naturally // Components will remount when navigating to different routes, // but will be reused when only params change on the same route return h(component.value) } }, { name: 'RouterView', // The prop types are defined above. Vue requires manually defining the prop names themselves here to distinguish from attrs // eslint-disable-next-line vue/require-prop-types props: ['name'], }) } ================================================ FILE: src/compositions/useComponentsStore.ts ================================================ import { inject, InjectionKey } from 'vue' import { RouterNotInstalledError } from '@/errors/routerNotInstalledError' import { ComponentsStore } from '@/services/createComponentsStore' import { createRouterKeyStore } from '@/services/createRouterKeyStore' import { Router } from '@/types/router' export const getComponentsStoreKey = createRouterKeyStore() export function createUseComponentsStore(routerKey: InjectionKey) { const componentsStoreKey = getComponentsStoreKey(routerKey) return () => { const store = inject(componentsStoreKey) if (!store) { throw new RouterNotInstalledError() } return store } } ================================================ FILE: src/compositions/useEventListener.ts ================================================ import { onUnmounted, Ref, watch } from 'vue' export function useEventListener(element: Ref, event: K, handler: (event: HTMLElementEventMap[K]) => void): void { watch(element, (element, previousElement) => { if (element) { element.addEventListener(event, handler) } if (previousElement) { previousElement.removeEventListener(event, handler) } }, { immediate: true }) onUnmounted(() => { if (element.value) { element.value.removeEventListener(event, handler) } }) } ================================================ FILE: src/compositions/useLink.ts ================================================ import { InjectionKey, MaybeRefOrGetter, computed, toValue } from 'vue' import { createUsePrefetching } from '@/compositions/usePrefetching' import { createUseRouter } from '@/compositions/useRouter' import { ResolvedRoute } from '@/types/resolved' import { RouterPushOptions } from '@/types/routerPush' import { RouteParamsByKey } from '@/types/routeWithParams' import { UrlString, isUrlString } from '@/types/urlString' import { AllPropertiesAreOptional } from '@/types/utilities' import { createIsRoute } from '@/guards/routes' import { combineUrlSearchParams } from '@/utilities/urlSearchParams' import { isDefined } from '@/utilities/guards' import { Router, RouterRouteName, RouterRoutes } from '@/types/router' import { UseLink, UseLinkOptions } from '@/types/useLink' type UseLinkArgs< TRouter extends Router, TSource extends RouterRouteName, TParams = RouteParamsByKey, TSource> > = AllPropertiesAreOptional extends true ? [params?: MaybeRefOrGetter, options?: MaybeRefOrGetter] : [params: MaybeRefOrGetter, options?: MaybeRefOrGetter] type UseLinkFunction = { >(name: MaybeRefOrGetter, ...args: UseLinkArgs): UseLink, (url: MaybeRefOrGetter, options?: MaybeRefOrGetter): UseLink, (resolvedRoute: MaybeRefOrGetter, options?: MaybeRefOrGetter): UseLink, (source: MaybeRefOrGetter, paramsOrOptions?: MaybeRefOrGetter | UseLinkOptions>, maybeOptions?: MaybeRefOrGetter): UseLink, } export function createUseLink(routerKey: InjectionKey): UseLinkFunction { const useRouter = createUseRouter(routerKey) const usePrefetching = createUsePrefetching(routerKey) const isRoute = createIsRoute(routerKey) return ( source: MaybeRefOrGetter, paramsOrOptions: MaybeRefOrGetter | UseLinkOptions> = {}, maybeOptions: MaybeRefOrGetter = {}, ) => { const router = useRouter() const route = computed(() => { const sourceValue = toValue(source) if (typeof sourceValue !== 'string') { return sourceValue } if (isUrlString(sourceValue)) { return router.find(sourceValue, toValue(maybeOptions)) } return router.resolve(sourceValue, toValue(paramsOrOptions), toValue(maybeOptions)) }) const href = computed(() => { if (route.value) { return route.value.href } const sourceValue = toValue(source) if (isUrlString(sourceValue)) { return sourceValue } console.error(new Error('Failed to resolve route in RouterLink.')) return undefined }) const isMatch = computed(() => isRoute(router.route) && router.route.matches.some((match) => match.id === route.value?.id)) const isExactMatch = computed(() => router.route.id === route.value?.id) const isActive = computed(() => isRoute(router.route) && isDefined(route.value) && router.route.href.startsWith(route.value.href)) const isExactActive = computed(() => router.route.href === route.value?.href) const isExternal = computed(() => !!href.value && router.isExternal(href.value)) const linkOptions = computed(() => { const sourceValue = toValue(source) return typeof sourceValue !== 'string' || isUrlString(sourceValue) ? toValue(paramsOrOptions) : toValue(maybeOptions) }) const { element, commit } = usePrefetching(() => ({ route: route.value, routerPrefetch: router.prefetch, linkPrefetch: linkOptions.value.prefetch, })) const push: UseLink['push'] = (pushOptions) => { commit() const options: RouterPushOptions = { replace: pushOptions?.replace ?? linkOptions.value.replace, query: combineUrlSearchParams(pushOptions?.query, linkOptions.value.query), hash: pushOptions?.hash ?? linkOptions.value.hash, state: { ...linkOptions.value.state, ...pushOptions?.state }, } const sourceValue = toValue(source) if (isUrlString(sourceValue) || typeof sourceValue === 'object') { return router.push(sourceValue, options) } return router.push(sourceValue, toValue(paramsOrOptions), options) } const replace: UseLink['replace'] = (options) => { return push({ ...options, replace: true }) } return { element, route, href, isMatch, isExactMatch, isActive, isExactActive, isExternal, push, replace, } } } ================================================ FILE: src/compositions/usePrefetching.ts ================================================ import { InjectionKey, MaybeRefOrGetter, ref, Ref, toValue, watch } from 'vue' import { createUsePropStore } from '@/compositions/usePropStore' import { isWithComponent, isWithComponents } from '@/types/createRouteOptions' import type { PrefetchConfigs, PrefetchStrategy } from '@/types/prefetch' import { getPrefetchOption } from '@/utilities/prefetch' import { ResolvedRoute } from '@/types/resolved' import { isAsyncComponent } from '@/utilities/components' import { useVisibilityObserver } from './useVisibilityObserver' import { useEventListener } from './useEventListener' import { Router } from '@/types/router' type UsePrefetchingConfig = PrefetchConfigs & { route: ResolvedRoute | undefined, } type UsePrefetching = { element: Ref, commit: () => void, } type UsePrefetchingFunction = (config: MaybeRefOrGetter) => UsePrefetching export function createUsePrefetching(routerKey: InjectionKey): UsePrefetchingFunction { const usePropStore = createUsePropStore(routerKey) return (config) => { const prefetchedProps = new Map>() const element = ref() const { getPrefetchProps, setPrefetchProps } = usePropStore() const { isElementVisible } = useVisibilityObserver(element) const commit: UsePrefetching['commit'] = () => { const props = Array.from(prefetchedProps.values()).reduce((accumulator, value) => { Object.assign(accumulator, value) return accumulator }, {}) setPrefetchProps(props) } watch(() => toValue(config), ({ route, ...configs }) => { prefetchedProps.clear() if (!route) { return } doPrefetchingForStrategy('eager', route, configs) }, { immediate: true }) watch(isElementVisible, (isVisible) => { const { route, ...configs } = toValue(config) if (!route || !isVisible) { return } doPrefetchingForStrategy('lazy', route, configs) }, { immediate: true }) useEventListener(element, 'focusin', handleIntentEvent) useEventListener(element, 'mouseover', handleIntentEvent) function handleIntentEvent(): void { const { route, ...configs } = toValue(config) if (!route) { return } doPrefetchingForStrategy('intent', route, configs) } function doPrefetchingForStrategy(strategy: PrefetchStrategy, route: ResolvedRoute, configs: PrefetchConfigs): void { prefetchComponentsForRoute(strategy, route, configs) if (!prefetchedProps.has(strategy)) { prefetchedProps.set(strategy, getPrefetchProps(strategy, route, configs)) } } return { element, commit, } } } function prefetchComponentsForRoute(strategy: PrefetchStrategy, route: ResolvedRoute, configs: PrefetchConfigs): void { route.matches.forEach((route) => { const routeStrategy = getPrefetchOption({ ...configs, routePrefetch: route.prefetch, }, 'components') if (routeStrategy !== strategy) { return } if (isWithComponent(route) && isAsyncComponent(route.component)) { route.component.__asyncLoader() } if (isWithComponents(route)) { Object.values(route.components).forEach((component) => { if (isAsyncComponent(component)) { component.__asyncLoader() } }) } }) } ================================================ FILE: src/compositions/usePropStore.ts ================================================ import { inject, InjectionKey } from 'vue' import { RouterNotInstalledError } from '@/errors/routerNotInstalledError' import { PropStore } from '@/services/createPropStore' import { createRouterKeyStore } from '@/services/createRouterKeyStore' import { Router } from '@/types/router' export const getPropStoreInjectionKey = createRouterKeyStore() type UsePropStore = () => PropStore export function createUsePropStore(routerKey: InjectionKey): UsePropStore { const propStoreKey = getPropStoreInjectionKey(routerKey) return (): PropStore => { const store = inject(propStoreKey) if (!store) { throw new RouterNotInstalledError() } return store } } ================================================ FILE: src/compositions/useQueryValue.browser.spec.ts ================================================ import { createRouter } from '@/services/createRouter' import { createRoute } from '@/services/createRoute' import { expect, test } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { useQueryValue, withDefault } from '@/main' test('returns correct value and values when key does not exist', async () => { const root = createRoute({ name: 'root', path: '/', }) const router = createRouter([root], { initialUrl: '/', }) await router.start() const component = { template: 'empty', setup() { return useQueryValue('foo') }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.value).toBe(null) expect(wrapper.vm.values).toEqual([]) }) test('returns correct value and values when key does exist', async () => { const root = createRoute({ name: 'root', path: '/', }) const router = createRouter([root], { initialUrl: '/?foo=1&foo=2', }) await router.start() const component = { template: 'empty', setup() { return useQueryValue('foo') }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.value).toBe('1') expect(wrapper.vm.values).toEqual(['1', '2']) }) test('returns correct value and values when a param is used', async () => { const root = createRoute({ name: 'root', path: '/', }) const router = createRouter([root], { initialUrl: '/?foo=1&foo=2', }) await router.start() const component = { template: 'empty', setup() { return useQueryValue('foo', Number) }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.value).toBe(1) expect(wrapper.vm.values).toEqual([1, 2]) }) test('returns correct value and values when a param is used that has a default value', async () => { const root = createRoute({ name: 'root', path: '/', }) const router = createRouter([root], { initialUrl: '/', }) await router.start() const component = { template: 'empty', setup() { return useQueryValue('foo', withDefault(Number, 3)) }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.value).toBe(3) expect(wrapper.vm.values).toEqual([3]) }) test('updates value and values when the query string changes', async () => { const root = createRoute({ name: 'root', path: '/', }) const router = createRouter([root], { initialUrl: '/?foo=1&foo=2', }) await router.start() const component = { template: 'empty', setup() { return useQueryValue('foo', Number) }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.value).toBe(1) expect(wrapper.vm.values).toEqual([1, 2]) await router.push('/?foo=3') expect(wrapper.vm.value).toBe(3) expect(wrapper.vm.values).toEqual([3]) }) test('updates value and values when a param is used that has a default value', async () => { const root = createRoute({ name: 'root', path: '/', }) const router = createRouter([root], { initialUrl: '/', }) await router.start() const component = { template: 'empty', setup() { return useQueryValue('foo', withDefault(Number, 3)) }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.value).toBe(3) expect(wrapper.vm.values).toEqual([3]) await router.push('/?foo=4') expect(wrapper.vm.value).toBe(4) expect(wrapper.vm.values).toEqual([4]) }) test('updates the query string when the value is set', async () => { const root = createRoute({ name: 'root', path: '/', query: { '?tab': withDefault(Number, 1), }, }) const router = createRouter([root], { initialUrl: '/?foo=1&foo=2', }) await router.start() const component = { template: 'empty', setup() { const { value } = useQueryValue('foo', Number) value.value = 3 }, } mount(component, { global: { plugins: [router], }, }) await flushPromises() expect(location.search).toBe('?tab=1&foo=3') }) test('updates the query string when the values is set', async () => { const root = createRoute({ name: 'root', path: '/', }) const router = createRouter([root], { initialUrl: '/?foo=1&foo=2', }) await router.start() const component = { template: 'empty', setup() { const { values } = useQueryValue('foo', Number) values.value = [3, 4] }, } mount(component, { global: { plugins: [router], }, }) await flushPromises() expect(router.route.query.toString()).toBe('foo=3&foo=4') }) test('removes the query string when the remove method is called', async () => { const root = createRoute({ name: 'root', path: '/', }) const router = createRouter([root], { initialUrl: '/?foo=1&foo=2', }) await router.start() const component = { template: 'empty', setup() { const { remove } = useQueryValue('foo') remove() }, } mount(component, { global: { plugins: [router], }, }) await flushPromises() expect(router.route.query.toString()).toBe('') }) ================================================ FILE: src/compositions/useQueryValue.spec-d.ts ================================================ import { Ref } from 'vue' import { expectTypeOf, test } from 'vitest' import { useQueryValue, withDefault } from '@/main' test('value is Ref when no param is provided', () => { const { value } = useQueryValue('foo') expectTypeOf(value).toEqualTypeOf>() }) test('value is Ref when param has no default', () => { const { value } = useQueryValue('foo', Number) expectTypeOf(value).toEqualTypeOf>() }) test('value is Ref (no null) when param has default', () => { const { value } = useQueryValue('foo', withDefault(Number, 3)) expectTypeOf(value).toEqualTypeOf>() }) test('values is Ref when no param is provided', () => { const { values } = useQueryValue('foo') expectTypeOf(values).toEqualTypeOf>() }) test('values is Ref when param has no default', () => { const { values } = useQueryValue('foo', Number) expectTypeOf(values).toEqualTypeOf>() }) test('values is Ref (no null) when param has default', () => { const { values } = useQueryValue('foo', withDefault(Number, 3)) expectTypeOf(values).toEqualTypeOf>() }) test('remove is () => void', () => { const { remove } = useQueryValue('foo') expectTypeOf(remove).toEqualTypeOf<() => void>() }) ================================================ FILE: src/compositions/useQueryValue.ts ================================================ import { computed, Ref, MaybeRefOrGetter, toValue, InjectionKey } from 'vue' import { createUseRoute } from './useRoute' import { Router } from '@/types/router' import { Param, ParamGetSet } from '@/types/paramTypes' import { ExtractParamType } from '@/types/params' import { safeGetParamValue, setParamValue } from '@/services/params' import { isParamWithDefault } from '@/services/withDefault' type UseQueryValue = { value: Ref, values: Ref[]>, remove: () => void, } type UseQueryValueFunction = { (key: MaybeRefOrGetter): UseQueryValue, ( key: MaybeRefOrGetter, param: TParam, ): TParam extends Required>> ? UseQueryValue> : UseQueryValue | null>, } export function createUseQueryValue(key: InjectionKey): UseQueryValueFunction { const useRoute = createUseRoute(key) return (key: MaybeRefOrGetter, param: Param = String): UseQueryValue => { const route = useRoute() const value = computed({ get() { const value = route.query.get(toValue(key)) if (value === null) { if (isParamWithDefault(param)) { return param.defaultValue } return null } return safeGetParamValue(value, { param }) }, set(value) { route.query.set(toValue(key), setParamValue(value, { param })) }, }) const values = computed({ get() { const values = route.query.getAll(toValue(key)) if (values.length === 0 && isParamWithDefault(param)) { return [param.defaultValue] } return values .map((value) => safeGetParamValue(value, { param })) .filter((value) => value !== null) }, set(values) { const query = new URLSearchParams(route.query) query.delete(toValue(key)) values.forEach((value) => { query.append(toValue(key), setParamValue(value, { param })) }) route.query = query }, }) return { value, values, remove: () => { route.query.delete(toValue(key)) }, } } } ================================================ FILE: src/compositions/useRejection.ts ================================================ import { InjectionKey, inject } from 'vue' import { RouterNotInstalledError } from '@/errors/routerNotInstalledError' import { RouterRejection, RouterRejections } from '@/types/rejection' import { createRouterKeyStore } from '@/services/createRouterKeyStore' import { Router } from '@/types/router' export const getRouterRejectionInjectionKey = createRouterKeyStore() export function createUseRejection(routerKey: InjectionKey): () => RouterRejection> export function createUseRejection(routerKey: InjectionKey): () => RouterRejection { const routerRejectionKey = getRouterRejectionInjectionKey(routerKey) return (): RouterRejection => { const rejection = inject(routerRejectionKey) if (!rejection) { throw new RouterNotInstalledError() } return rejection } } ================================================ FILE: src/compositions/useRoute.browser.spec.ts ================================================ import { mount } from '@vue/test-utils' import { expect, test } from 'vitest' import { useRoute } from '@/main' import { UseRouteInvalidError } from '@/errors/useRouteInvalidError' import { createRouter } from '@/services/createRouter' import { getError, routes } from '@/utilities/testHelpers' test('when given no routeKey returns the router route', async () => { const router = createRouter(routes, { initialUrl: '/routeA', }) await router.start() const component = { template: 'foo', setup() { const route = useRoute() return { route } }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.route).toBe(router.route) }) test('when given a routeKey that matches the current route returns the router route', async () => { const router = createRouter(routes, { initialUrl: '/parentB', }) await router.start() const component = { template: 'foo', setup() { const route = useRoute('parentB') return { route } }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.route).toBe(router.route) }) test('when given a routeKey that matches exactly the current route returns the router route', async () => { const router = createRouter(routes, { initialUrl: '/parentA/parentAParam/childA/childAParam', }) await router.start() const component = { template: 'foo', setup() { const route = useRoute('parentA.childA') return { route } }, } const wrapper = mount(component, { global: { plugins: [router], }, }) expect(wrapper.vm.route).toBe(router.route) }) test('when given a routeKey that does not match the current route, throws UseRouteInvalidError', async () => { const router = createRouter(routes, { initialUrl: '/parentB', }) await router.start() const component = { template: 'foo', setup() { const route = useRoute('parentC') return { route } }, } const error = getError(() => { mount(component, { global: { plugins: [router], }, }) }) expect(error).toBeInstanceOf(UseRouteInvalidError) }) test('when given a routeKey that does not match exactly the current route, throws UseRouteInvalidError', async () => { const router = createRouter(routes, { initialUrl: '/parentA/parentAParam/childA/childAParam', }) await router.start() const component = { template: 'foo', setup() { const route = useRoute('parentA', { exact: true }) return { route } }, } const error = getError(() => { mount(component, { global: { plugins: [router], }, }) }) expect(error).toBeInstanceOf(UseRouteInvalidError) }) ================================================ FILE: src/compositions/useRoute.spec-d.ts ================================================ import { describe, expectTypeOf, test } from 'vitest' import { createUseRoute } from '@/compositions/useRoute' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { component } from '@/utilities/testHelpers' import { withParams } from '@/services/withParams' const parentA = createRoute({ name: 'parentA', path: '/parentA', }) const childA = createRoute({ parent: parentA, name: 'childA', path: '[?foo]', query: withParams('bar=[?bar]', { bar: Boolean }), component, }) const parentB = createRoute({ name: 'parentB', path: '/parentB', component, }) const routes = [parentA, childA, parentB] as const const { key } = createRouter(routes) const useRoute = createUseRoute(key) test('without arguments returns full route union', () => { const route = useRoute() expectTypeOf().toEqualTypeOf<'parentA' | 'childA' | 'parentB'>() }) describe('with exact', () => { test('narrows to exact route name', () => { const route = useRoute('parentA', { exact: true }) expectTypeOf().toEqualTypeOf<'parentA'>() }) test('narrows params', () => { const routeA = useRoute('parentA', { exact: true }) expectTypeOf().toEqualTypeOf<{}>() const routeChild = useRoute('childA', { exact: true }) expectTypeOf().toEqualTypeOf<{ foo?: string | undefined, bar?: boolean | undefined, }>() }) }) describe('without exact', () => { test('narrows to route and descendants', () => { const route = useRoute('parentA') expectTypeOf().toEqualTypeOf<'parentA' | 'childA'>() }) test('narrows params to include descendants', () => { const route = useRoute('parentA') expectTypeOf().toEqualTypeOf<{} | { foo?: string | undefined, bar?: boolean | undefined, }>() }) test('for leaf route narrows to just that route', () => { const route = useRoute('parentB') expectTypeOf().toEqualTypeOf<'parentB'>() }) test('with explicit exact false behaves same as without exact', () => { const route = useRoute('parentA', { exact: false }) expectTypeOf().toEqualTypeOf<'parentA' | 'childA'>() }) }) test('siblings are not matched when an unnamed parent is present', () => { const parent = createRoute({ path: '/', component, }) const childA = createRoute({ parent: parent, name: 'childA', component, }) const childB = createRoute({ parent: parent, name: 'childB', component, }) const grandChild = createRoute({ parent: childA, name: 'grandChild', component, }) const routes = [ parent, childA, childB, grandChild, ] as const const { key } = createRouter(routes) const useRoute = createUseRoute(key) const anyRoute = useRoute() expectTypeOf().toEqualTypeOf<'childA' | 'childB' | 'grandChild'>() const childARoute = useRoute('childA') expectTypeOf().toEqualTypeOf<'childA' | 'grandChild'>() const childBRoute = useRoute('childB') expectTypeOf().toEqualTypeOf<'childB'>() const grandChildRoute = useRoute('grandChild') expectTypeOf().toEqualTypeOf<'grandChild'>() }) ================================================ FILE: src/compositions/useRoute.ts ================================================ import { InjectionKey, watch } from 'vue' import { createUseRouter } from '@/compositions/useRouter' import { UseRouteInvalidError } from '@/errors/useRouteInvalidError' import { IsRouteOptions, createIsRoute, RouteWithMatch } from '@/guards/routes' import { Router, RouterRouteName } from '@/types/router' import { RouterRoute } from '@/types/routerRoute' type UseRouteFunction = { (): TRouter['route'], < const TRouteName extends RouterRouteName >(routeName: TRouteName, options: IsRouteOptions & { exact: true }): TRouter['route'] & { name: TRouteName }, < const TRouteName extends RouterRouteName >(routeName: TRouteName, options?: IsRouteOptions): RouteWithMatch, } export function createUseRoute(routerKey: InjectionKey): UseRouteFunction export function createUseRoute(routerKey: InjectionKey): (routeName?: string, options?: IsRouteOptions) => RouterRoute { const useRouter = createUseRouter(routerKey) const isRoute = createIsRoute(routerKey) return (routeName?: string, options?: IsRouteOptions): RouterRoute => { const router = useRouter() function checkRouteNameIsValid(): void { if (!routeName) { return } const routeNameIsValid = isRoute(router.route, routeName, options) if (!routeNameIsValid) { throw new UseRouteInvalidError(routeName, router.route.name) } } watch(router.route, checkRouteNameIsValid, { immediate: true, deep: true }) return router.route } } ================================================ FILE: src/compositions/useRouter.ts ================================================ import { InjectionKey, inject } from 'vue' import { RouterNotInstalledError } from '@/errors/routerNotInstalledError' import { Router } from '@/types/router' export function createUseRouter(routerKey: InjectionKey): () => TRouter { return () => { const router = inject(routerKey) if (!router) { throw new RouterNotInstalledError() } return router } } ================================================ FILE: src/compositions/useRouterDepth.ts ================================================ import { inject, InjectionKey, provide } from 'vue' import { Router } from '@/types/router' import { createRouterKeyStore } from '@/services/createRouterKeyStore' const getDepthInjectionKey = createRouterKeyStore() type UseRouterDepthProps = { increment?: boolean, } type UseRouterDepthFunction = (props?: UseRouterDepthProps) => number export function createUseRouterDepth(routerKey: InjectionKey): UseRouterDepthFunction { const depthKey = getDepthInjectionKey(routerKey) return ({ increment = false }: UseRouterDepthProps = {}): number => { const depth = inject(depthKey, 0) if (increment) { provide(depthKey, depth + 1) } return depth } } ================================================ FILE: src/compositions/useRouterHooks.ts ================================================ import { RouterNotInstalledError } from '@/errors/routerNotInstalledError' import { getRouterHooksKey, RouterHooks } from '@/services/createRouterHooks' import { Router } from '@/types/router' import { inject, InjectionKey } from 'vue' export function createUseRouterHooks(routerKey: InjectionKey) { const routerHooksKey = getRouterHooksKey(routerKey) return (): RouterHooks => { const hooks = inject(routerHooksKey) if (!hooks) { throw new RouterNotInstalledError() } return hooks } } ================================================ FILE: src/compositions/useVisibilityObserver.ts ================================================ import { RouterNotInstalledError } from '@/errors/routerNotInstalledError' import { VisibilityObserver } from '@/services/createVisibilityObserver' import { computed, inject, InjectionKey, onUnmounted, Ref, watch } from 'vue' type UseVisibilityObserver = { isElementVisible: Ref, } export const visibilityObserverKey: InjectionKey = Symbol('visibilityObserver') export function useVisibilityObserver(element: Ref): UseVisibilityObserver { const observer = inject(visibilityObserverKey) if (!observer) { throw new RouterNotInstalledError() } watch(element, (element, previousElement) => { if (element) { observer.observe(element) } if (previousElement) { observer.unobserve(previousElement) } }, { immediate: true }) onUnmounted(() => { if (element.value) { observer.unobserve(element.value) } }) const isElementVisible = computed(() => { if (!element.value) { return false } return observer.isElementVisible(element.value) }) return { isElementVisible, } } ================================================ FILE: src/devtools/createRouterDevtools.ts ================================================ import { setupDevtoolsPlugin } from '@vue/devtools-api' import { App, watch } from 'vue' import { Router } from '@/types/router' import { Routes, Route } from '@/types/route' import { createUniqueIdSequence } from '@/services/createUniqueIdSequence' import { isBrowser } from '@/utilities' import { getDevtoolsLabel } from './getDevtoolsLabel' import { CustomInspectorNode, CustomInspectorState, InspectorNodeTag } from './types' import { shouldShowRoute } from './filters' import { isUrl } from '@/types/url' // Support multiple router instances const getRouterId = createUniqueIdSequence() // Colors for route tags const CYAN_400 = 0x22d3ee const GREEN_500 = 0x22c55e const GREEN_600 = 0x16a34a type RouteMatchOptions = { match: boolean, exactMatch: boolean, } /** * Formats a route for the inspector tree. */ function getInspectorNodeForRoute( route: Route, options: RouteMatchOptions = { match: false, exactMatch: false }, ): CustomInspectorNode { const tags: InspectorNodeTag[] = [] // Add route name tag if (route.name) { tags.push({ label: route.name, textColor: 0, backgroundColor: CYAN_400, }) } if (options.match) { tags.push({ label: 'match', textColor: 0, backgroundColor: GREEN_500, }) } if (options.exactMatch) { tags.push({ label: 'exact-match', textColor: 0, backgroundColor: GREEN_600, }) } return { id: route.id, label: route.name, tags, } } /** * Calculates match status for a route based on the current route. */ function getRouteMatchStatus( route: Route, currentRoute: Router['route'] | undefined, ): RouteMatchOptions { if (!currentRoute || !('id' in currentRoute) || !('matches' in currentRoute)) { return { match: false, exactMatch: false } } const exactMatch = currentRoute.id === route.id const match = currentRoute.matches.some((match: { id: string }) => match.id === route.id) return { match, exactMatch } } /** * Formats a Route definition for the inspector. * Shows the route definition properties (path pattern, name, meta, etc.) */ function getInspectorStateOptionsForRoute(route: Route): CustomInspectorState[string] { const fields: CustomInspectorState[string] = [] if (!isUrl(route)) { return fields } // Route name fields.push({ editable: false, key: 'name', value: route.name, }) // Route path pattern fields.push({ editable: false, key: 'path', value: route.schema.path.value, }) // Route query fields.push({ editable: false, key: 'query', value: route.schema.query.value, }) // Route hash fields.push({ editable: false, key: 'hash', value: route.schema.hash.value, }) // Route meta fields.push({ editable: false, key: 'meta', value: route.meta, }) return fields } type RouterDevtoolsProps = { router: Router, app: App, routes: Routes, } /** * Sets up Vue DevTools integration for Kitbag Router. */ export function setupRouterDevtools({ router, app, routes }: RouterDevtoolsProps): void { if (!isBrowser()) { return } // Prevent double registration if (router.hasDevtools) { return } router.hasDevtools = true // Support multiple router instances const id = getRouterId() const routesIdMap = new Map(routes.map((route) => [route.id, route])) const routerInspectorId = `kitbag-router-routes.${id}` as const const pluginId = `kitbag-router.${id}` as const setupDevtoolsPlugin( { id: pluginId, label: 'Kitbag Router', packageName: '@kitbag/router', homepage: 'https://router.kitbag.dev/', componentStateTypes: ['Routing'], logo: 'https://kitbag.dev/kitbag-logo-circle.svg', app, }, (api) => { // Add inspector for routes api.addInspector({ id: routerInspectorId, label: getDevtoolsLabel('Routes', id), treeFilterPlaceholder: 'Search routes', }) // Handle inspector tree (list of routes) api.on.getInspectorTree((payload) => { if (payload.app !== app || payload.inspectorId !== routerInspectorId) { return } payload.rootNodes = routes .filter((route) => shouldShowRoute({ route, payload })) .map((route) => { const matchStatus = getRouteMatchStatus(route, router.route) return getInspectorNodeForRoute(route, matchStatus) }) }) // Watch router.route and update inspector tree reactively watch( () => router.route, () => { // Send updated tree to devtools (this will trigger getInspectorTree handler) api.sendInspectorTree(routerInspectorId) }, { deep: true, immediate: false }, ) // Handle inspector state (individual route details) api.on.getInspectorState((payload) => { if (payload.app !== app || payload.inspectorId !== routerInspectorId) { return } // Find route by ID const route = routesIdMap.get(payload.nodeId) if (!route) { return } payload.state = { options: getInspectorStateOptionsForRoute(route), } }) // Initial refresh api.sendInspectorTree(routerInspectorId) api.sendInspectorState(routerInspectorId) }, ) } ================================================ FILE: src/devtools/filters.ts ================================================ import { Route } from '@/types/route' import { InspectorTreePayload } from './types' import { isUrl } from '@/types/url' type RouteFilterOptions = { route: Route, payload: InspectorTreePayload, } type RouteFilter = (options: RouteFilterOptions) => boolean /** * Checks if a route has a name and should be shown */ const routeHasName: RouteFilter = ({ route }) => { return Boolean(route.name) } /** * Checks if a route matches the filter text. * Searches in route name, path, and meta properties. */ const routeMatchesFilter: RouteFilter = ({ route, payload }) => { if (!payload.filter) { return true } if (!isUrl(route)) { return false } const filterLower = payload.filter.toLowerCase() // Check route name if (route.name.toLowerCase().includes(filterLower)) { return true } // Check route path if (route.schema.path.value.toLowerCase().includes(filterLower)) { return true } return false } /** * Checks if a route should be shown based on multiple filters. */ export const shouldShowRoute: RouteFilter = (options) => { return routeHasName(options) && routeMatchesFilter(options) } ================================================ FILE: src/devtools/getDevtoolsLabel.ts ================================================ /** * Generates a DevTools label with optional router instance ID. * If the router ID is greater than 1, appends the ID to the label. * * @param label - Base label for the DevTools element * @param routerId - Router instance ID (string from createUniqueIdSequence) * @returns Label with ID appended if routerId > '1' * * @example * getDevtoolsLabel('Routes', '1') // 'Routes' * getDevtoolsLabel('Routes', '2') // 'Routes 2' */ export function getDevtoolsLabel(label: string, routerId: string): string { return routerId !== '1' ? `${label} ${routerId}` : label } ================================================ FILE: src/devtools/types.ts ================================================ import { setupDevtoolsPlugin } from '@vue/devtools-api' // Extract types from DevTools API by inferring from the callback parameter type ExtractAPIFromCallback = Parameters[1]>[0] type ExtractInspectorTreeHandler = Parameters[0] type ExtractInspectorStateHandler = Parameters[0] type ExtractInspectorStatePayload = Parameters[0] // Type definitions for the inspector type InspectorTreePayload = Parameters[0] type CustomInspectorNode = InspectorTreePayload['rootNodes'][number] type InspectorNodeTag = NonNullable[number] type CustomInspectorState = ExtractInspectorStatePayload['state'] export type { CustomInspectorNode, InspectorTreePayload, InspectorNodeTag, CustomInspectorState } ================================================ FILE: src/errors/contextAbortError.ts ================================================ import { CallbackContextAbort } from '@/types/callbackContext' import { ContextError } from './contextError' export class ContextAbortError extends ContextError { public response: CallbackContextAbort public constructor() { super('Uncaught ContextAbortError') this.response = { status: 'ABORT' } } } ================================================ FILE: src/errors/contextError.ts ================================================ export class ContextError extends Error {} ================================================ FILE: src/errors/contextPushError.ts ================================================ import { CallbackContextPush } from '@/types/callbackContext' import { ContextError } from './contextError' import { RouterPush } from '@/main' export class ContextPushError extends ContextError { public response: CallbackContextPush public constructor(to: unknown[]) { super('Uncaught ContextPushError') this.response = { status: 'PUSH', to: to as Parameters } } } ================================================ FILE: src/errors/contextRejectionError.ts ================================================ import { CallbackContextReject } from '@/types/callbackContext' import { ContextError } from './contextError' export class ContextRejectionError extends ContextError { public response: CallbackContextReject public constructor(type: string) { super('Uncaught ContextRejectionError') this.response = { status: 'REJECT', type } } } ================================================ FILE: src/errors/duplicateNamesError.ts ================================================ /** * An error thrown when duplicate names are detected in a route. * Names must be unique. */ export class DuplicateNamesError extends Error { /** * Constructs a new DuplicateNamesError instance with a message indicating the problematic name. * @param name - The name of the name that was duplicated. */ public constructor(name: string) { super(`Invalid Name "${name}": Router does not support multiple routes with the same name. All name names must be unique.`) } } ================================================ FILE: src/errors/duplicateParamsError.ts ================================================ /** * An error thrown when duplicate parameters are detected in a route. * Param names must be unique. This includes params defined in a path * parent and params defined in the query. * @group Errors */ export class DuplicateParamsError extends Error { /** * Constructs a new DuplicateParamsError instance with a message indicating the problematic parameter. * @param paramName - The name of the parameter that was duplicated. */ public constructor(paramName: string) { super(`Invalid Param "${paramName}": Router does not support multiple params by the same name. All param names must be unique.`) } } ================================================ FILE: src/errors/initialRouteMissingError.ts ================================================ export class InitialRouteMissingError extends Error { public constructor() { super('initialUrl must be set if window.location is unavailable') } } ================================================ FILE: src/errors/invalidRouteParamValueError.ts ================================================ import { Param } from '@/types/paramTypes' export type InvalidRouteParamValueErrorContext = { message?: string, param?: Param, value?: unknown, isOptional?: boolean, isGetter?: boolean, isSetter?: boolean, } export class InvalidRouteParamValueError extends Error { public context: InvalidRouteParamValueErrorContext public constructor(context: InvalidRouteParamValueErrorContext = {}) { super(context.message ?? 'Uncaught InvalidRouteParamValueError') this.context = { isOptional: false, isGetter: false, isSetter: false, ...context, } } } ================================================ FILE: src/errors/invalidRouteRedirectError.ts ================================================ /** * An error thrown when a route does not support redirects. * @group Errors */ export class InvalidRouteRedirectError extends Error { /** * Constructs a new InvalidRouteRedirectError instance with a message indicating the problematic route redirect. * @param routeName - The name of the route that does not support redirects. */ public constructor(routeName: string) { super(`Invalid Route Redirect "${routeName}": Route does not support redirects. Use createRouteRedirects to create redirects.`) } } ================================================ FILE: src/errors/metaPropertyConflict.ts ================================================ /** * An error thrown when a parent's meta has the same key as a child and the types are not compatible. * A child's meta can override properties of the parent, however the types must match! * @group Errors */ export class MetaPropertyConflict extends Error { public constructor(property?: string) { super(`Child property on meta for ${property} conflicts with the parent meta.`) } } ================================================ FILE: src/errors/multipleRouteRedirectsError.ts ================================================ /** * An error thrown when duplicate parameters are detected in a route. * Param names must be unique. This includes params defined in a path * parent and params defined in the query. * @group Errors */ export class MultipleRouteRedirectsError extends Error { /** * Constructs a new MultipleRouteRedirectsError instance with a message indicating the problematic route redirect. * @param routeName - The name of the route that has multiple redirects. */ public constructor(routeName: string) { super(`Invalid Route Redirect "${routeName}": Router does not support multiple redirects to the same route. All redirects must be unique.`) } } ================================================ FILE: src/errors/routeNotFoundError.ts ================================================ /** * An error thrown when attempting to resolve a route that does not exist */ export class RouteNotFoundError extends Error { public constructor(source: string) { super(`Route not found: "${source}"`) } } ================================================ FILE: src/errors/routerNotInstalledError.ts ================================================ /** * An error thrown when an attempt is made to use routing functionality before the router has been installed. * @group Errors */ export class RouterNotInstalledError extends Error { public constructor() { super('Router not installed') } } ================================================ FILE: src/errors/useRouteInvalidError.ts ================================================ /** * An error thrown when there is a mismatch between an expected route and the one actually used. * @group Errors */ export class UseRouteInvalidError extends Error { /** * Constructs a new UseRouteInvalidError instance with a message that specifies both the given and expected route names. * This detailed error message aids in quickly identifying and resolving mismatches in route usage. * @param routeName - The route name that was incorrectly used. * @param actualRouteName - The expected route name that should have been used. */ public constructor(routeName: string, actualRouteName: string) { super(`useRoute called with incorrect route. Given ${routeName}, expected ${actualRouteName}`) } } ================================================ FILE: src/guards/routes.spec-d.ts ================================================ import { expectTypeOf, test } from 'vitest' import { createIsRoute } from '@/guards/routes' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { component } from '@/utilities/testHelpers' import { withParams } from '@/services/withParams' test('router route can be narrowed', () => { const parentA = createRoute({ name: 'parentA', path: '/parentA', }) const childA = createRoute({ parent: parentA, name: 'childA', path: '[?foo]', query: withParams('bar=[?bar]', { bar: Boolean }), component, }) const routes = [ parentA, childA, createRoute({ name: 'parentB', path: '/parentB', component, }), ] as const const { route, key } = createRouter(routes) const isRoute = createIsRoute(key) expectTypeOf().toEqualTypeOf<'parentA' | 'parentB' | 'childA'>() if (route.name === 'parentA') { expectTypeOf().toEqualTypeOf<'parentA'>() } if (isRoute(route, 'parentA', { exact: true })) { expectTypeOf().toEqualTypeOf<'parentA'>() } if (isRoute(route, 'parentB', { exact: true })) { expectTypeOf().toEqualTypeOf<'parentB'>() } if (isRoute(route, 'parentA', { exact: false })) { expectTypeOf().toEqualTypeOf<'parentA' | 'childA'>() } if (isRoute(route, 'parentB', { exact: false })) { expectTypeOf().toEqualTypeOf<'parentB'>() } if (isRoute(route, 'parentA')) { expectTypeOf().toEqualTypeOf<'parentA' | 'childA'>() } if (route.name === 'parentA') { expectTypeOf().toEqualTypeOf<{}>() } if (isRoute(route, 'parentA', { exact: true })) { expectTypeOf().toEqualTypeOf<{}>() } if (isRoute(route, 'parentA', { exact: false })) { expectTypeOf().toEqualTypeOf<{} | { foo?: string | undefined, bar?: boolean | undefined, }>() } if (isRoute(route, 'parentA')) { expectTypeOf().toEqualTypeOf<{} | { foo?: string | undefined, bar?: boolean | undefined, }>() } }) ================================================ FILE: src/guards/routes.ts ================================================ import { isRouterRoute } from '@/services/createRouterRoute' import type { RouterRoute } from '@/types/routerRoute' import { Router, RouterRouteName } from '@/types/router' import { InjectionKey } from 'vue' export type IsRouteOptions = { exact?: boolean, } export type RouteWithMatch< TRoute extends RouterRoute, TRouteName extends TRoute['name'] > = TRoute extends RouterRoute ? TRouteName extends TRoute['matches'][number]['name'] ? TRoute : never : never type IsRouteFunction = { /** * A type guard for determining if a value is a valid RouterRoute. * @param route - The value to check. * @returns `true` if the value is a valid RouterRoute, otherwise `false`. * @group Type Guards */ (route: unknown): route is RouterRoute, /** * A type guard for determining if a value is a valid RouterRoute with an exact match. * @param route - The value to check. * @param routeName - The expected route name. * @returns `true` if the value is a valid RouterRoute with an exact match, otherwise `false`. * @group Type Guards */ < TRoute extends TRouter['route'], TRouteName extends RouterRouteName >(route: TRoute, routeName: TRouteName, options: IsRouteOptions & { exact: true }): route is TRoute & { name: TRouteName }, /** * A type guard for determining if a value is a valid RouterRoute with a partial match. * @param route - The value to check. * @param routeName - The expected route name. * @returns `true` if the value is a valid RouterRoute with a partial match, otherwise `false`. * @group Type Guards */ < TRoute extends TRouter['route'], TRouteName extends TRoute['name'] >(route: TRoute, routeName: TRouteName, options?: IsRouteOptions): route is RouteWithMatch, /** * A type guard for determining if a value is a valid RouterRoute. * @param route - The value to check. * @param routeName - The expected route name. * @returns `true` if the value is a valid RouterRoute, otherwise `false`. * @group Type Guards */ (route: unknown, routeName?: string, options?: IsRouteOptions): boolean, } export function createIsRoute(routerKey: InjectionKey): IsRouteFunction { return (route: unknown, routeName?: string, { exact }: IsRouteOptions = {}): route is any => { if (!isRouterRoute(routerKey, route)) { return false } if (routeName === undefined) { return true } if (exact) { return route.matched.name === routeName } return route.matches.map((route) => route.name).includes(routeName) } } ================================================ FILE: src/keys.ts ================================================ export const routerInjectionKey = Symbol() ================================================ FILE: src/main.ts ================================================ // Types export type { WithHost, WithoutHost, WithParent, WithoutParent, CreateRouteOptions, PropsGetter, RouterViewPropsGetter, CreateRouteProps, ToRoute, } from './types/createRouteOptions' export type { RejectionHooks, HookRemove, BeforeHookLifecycle, AfterHookLifecycle, HookLifecycle, BeforeEnterHookContext, BeforeEnterHook, AddBeforeEnterHook, BeforeUpdateHookContext, BeforeUpdateHook, AddBeforeUpdateHook, BeforeLeaveHookContext, BeforeLeaveHook, AddBeforeLeaveHook, AfterEnterHookContext, AfterEnterHook, AddAfterEnterHook, AfterUpdateHookContext, AfterUpdateHook, AddAfterUpdateHook, AfterLeaveHookContext, AfterLeaveHook, AddAfterLeaveHook, RejectionHookContext, RejectionHook, AddRejectionHook, ErrorHookContext, ErrorHook, AddErrorHook, } from './types/hooks' export * from './types/paramTypes' export * from './types/prefetch' export * from './types/props' export * from './types/querySource' export type { RouteRedirects } from './types/redirects' export * from './types/register' export type { Rejection, RejectionType, Rejections } from './types/rejection' export * from './types/resolved' export type { Route, CreatedRouteOptions } from './types/route' export * from './types/router' export * from './types/routerLink' export type { RouterPlugin, CreateRouterPluginOptions } from './types/routerPlugin' export * from './types/routerPush' export * from './types/routerPush' export * from './types/routerReject' export * from './types/routerReplace' export * from './types/routerReplace' export * from './types/routerResolve' export * from './types/routerResolve' export * from './types/routerRoute' export * from './types/urlString' export type { Url, CreateUrlOptions, ParseUrlOptions } from './types/url' export * from './types/useLink' // Errors export { DuplicateParamsError } from './errors/duplicateParamsError' export { MetaPropertyConflict } from './errors/metaPropertyConflict' export { RouterNotInstalledError } from './errors/routerNotInstalledError' export { UseRouteInvalidError } from './errors/useRouteInvalidError' // Services export { createRoute } from './services/createRoute' export { createRejection } from './services/createRejection' export { createExternalRoute } from './services/createExternalRoute' export { createRouterAssets, type RouterAssets } from './services/createRouterAssets' export { withParams } from './services/withParams' export { withDefault } from './services/withDefault' export { createRouterPlugin } from './services/createRouterPlugin' export { unionOf } from './services/unionOf' export { arrayOf } from './services/arrayOf' export { tupleOf } from './services/tupleOf' export { literal } from './services/literal' export { createParam } from './services/createParam' export { createRouter } from './services/createRouter' export { createUrl } from './services/createUrl' // Assets import { routerInjectionKey } from './keys' import { createRouterAssets, RouterAssets } from './services/createRouterAssets' import { RegisteredRouter } from './types/register' const routerAssets = createRouterAssets(routerInjectionKey) /** * Registers a hook that is called before a route is left. Must be called from setup. * This is useful for performing actions or cleanups before navigating away from a route component. * * @param BeforeRouteHook - The hook callback function * @returns {RouteHookRemove} A function that removes the added hook. * @group Hooks */ export const onBeforeRouteLeave: RouterAssets['onBeforeRouteLeave'] = routerAssets.onBeforeRouteLeave /** * Registers a hook that is called before a route is updated. Must be called from setup. * This is particularly useful for handling changes in route parameters or query while staying within the same component. * * @param BeforeRouteHook - The hook callback function * @returns {RouteHookRemove} A function that removes the added hook. * @group Hooks */ export const onBeforeRouteUpdate: RouterAssets['onBeforeRouteUpdate'] = routerAssets.onBeforeRouteUpdate /** * Registers a hook that is called after a route has been left. Must be called during setup. * This can be used for cleanup actions after the component is no longer active, ensuring proper resource management. * * @param AfterRouteHook - The hook callback function * @returns {RouteHookRemove} A function that removes the added hook. * @group Hooks */ export const onAfterRouteLeave: RouterAssets['onAfterRouteLeave'] = routerAssets.onAfterRouteLeave /** * Registers a hook that is called after a route has been updated. Must be called during setup. * This is ideal for responding to updates within the same route, such as parameter changes, without full component reloads. * * @param AfterRouteHook - The hook callback function * @returns {RouteHookRemove} A function that removes the added hook. * @group Hooks */ export const onAfterRouteUpdate: RouterAssets['onAfterRouteUpdate'] = routerAssets.onAfterRouteUpdate /** * A guard to verify if a route or unknown value matches a given route name. * * @param routeName - The name of the route to check against the current route. * @returns True if the current route matches the given route name, false otherwise. * @group Type Guards */ export const isRoute: RouterAssets['isRoute'] = routerAssets.isRoute /** * A component to render the current route's component. * * @param props - The props to pass to the router view component. * @returns The router view component. * @group Components */ export const RouterView: RouterAssets['RouterView'] = routerAssets.RouterView /** * A component to render a link to a route or any url. * * @param props - The props to pass to the router link component. * @returns The router link component. * @group Components */ export const RouterLink: RouterAssets['RouterLink'] = routerAssets.RouterLink /** * A composition to access the current route or verify a specific route name within a Vue component. * This function provides two overloads: * 1. When called without arguments, it returns the current route from the router without types. * 2. When called with a route name, it checks if the current active route includes the specified route name. * * The function also sets up a reactive watcher on the route object from the router to continually check the validity of the route name * if provided, throwing an error if the validation fails at any point during the component's lifecycle. * * @template TRouteName - A string type that should match route name of the registered router, ensuring the route name exists. * @param routeName - Optional. The name of the route to validate against the current active routes. * @returns The current router route. If a route name is provided, it validates the route name first. * @throws {UseRouteInvalidError} Throws an error if the provided route name is not valid or does not match the current route. * @group Compositions */ export const useRoute: RouterAssets['useRoute'] = routerAssets.useRoute /** * A composition to access the installed router instance within a Vue component. * * @returns The installed router instance. * @throws {RouterNotInstalledError} Throws an error if the router has not been installed, * ensuring the component does not operate without routing functionality. * @group Compositions */ export const useRouter: RouterAssets['useRouter'] = routerAssets.useRouter /** * A composition to access a specific query value from the current route. * * @returns The query value from the router. * @group Compositions */ export const useQueryValue: RouterAssets['useQueryValue'] = routerAssets.useQueryValue /** * A composition to export much of the functionality that drives RouterLink component. * Also exports some useful context about routes relationship to current URL and convenience methods * for navigating. * * @param source - The name of the route or a valid URL. * @param params - If providing route name, this argument will expect corresponding params. * @param options - {@link RouterResolveOptions} Same options as router resolve. * @returns {UseLink} Reactive context values for as well as navigation methods. * @group Compositions */ export const useLink: RouterAssets['useLink'] = routerAssets.useLink /** * A composition to access the rejection from the router. * * @returns The rejection from the router. * @group Compositions */ export const useRejection: RouterAssets['useRejection'] = routerAssets.useRejection declare module 'vue' { export interface GlobalComponents { RouterView: typeof RouterView, RouterLink: typeof RouterLink, } } ================================================ FILE: src/models/hooks.ts ================================================ import { AfterEnterHook, AfterLeaveHook, AfterUpdateHook, BeforeEnterHook, BeforeLeaveHook, BeforeUpdateHook, ErrorHook, RejectionHook } from '@/types/hooks' import { RedirectHook } from '@/types/redirects' export class Hooks { public redirects = new Set() public onBeforeRouteEnter = new Set() public onBeforeRouteUpdate = new Set() public onBeforeRouteLeave = new Set() public onAfterRouteEnter = new Set() public onAfterRouteUpdate = new Set() public onAfterRouteLeave = new Set() public onError = new Set() public onRejection = new Set() } ================================================ FILE: src/services/arrayOf.spec.ts ================================================ import { expect, test } from 'vitest' import { arrayOf } from '@/services/arrayOf' import { getParamValue, setParamValue } from '@/services/params' import { InvalidRouteParamValueError } from '@/errors/invalidRouteParamValueError' test.each([ ['23', [23]], ['true,23,foo', [true, 23, 'foo']], ['1,2,3,4,5', [1, 2, 3, 4, 5]], ])('given an array of params with valid values, returns an array of values', (input, expected) => { const array = arrayOf([Number, Boolean, String]) const result = getParamValue(input, { param: array }) expect(result).toEqual(expected) }) test('given an array of params with an invalid value, throws InvalidRouteParamValueError', () => { const array = arrayOf([Number, Boolean]) const action: () => void = () => getParamValue('true, 23, foo', { param: array }) expect(action).toThrow(InvalidRouteParamValueError) }) test('given value is not an array, throws InvalidRouteParamValueError', () => { const array = arrayOf([Number, Boolean]) const action: () => void = () => setParamValue({}, { param: array }) expect(action).toThrow('Expected an array') }) test('given a separator, uses it to split the value', () => { const array = arrayOf([Number, Boolean, String], { separator: '|' }) const result = getParamValue('1|2|3', { param: array }) expect(result).toEqual([1, 2, 3]) }) ================================================ FILE: src/services/arrayOf.ts ================================================ import { Param, ParamGetSet } from '@/types/paramTypes' import { ExtractParamType } from '@/types/params' import { unionOf } from './unionOf' type ArrayOfOptions = { separator?: string, } const defaultOptions = { separator: ',', } satisfies ArrayOfOptions export function arrayOf(params: T, options: ArrayOfOptions = {}): ParamGetSet[]> { const { separator } = { ...defaultOptions, ...options } const union = unionOf(params) return { get: (value, extras) => { return value.split(separator).map((value) => union.get(value, extras)) }, set: (value, extras) => { if (!Array.isArray(value)) { throw extras.invalid('Expected an array') } return value.map((value) => union.set(value, extras)).join(separator) }, } } ================================================ FILE: src/services/combineHash.spec.ts ================================================ import { expect, test } from 'vitest' import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { combineHash } from '@/services/combineHash' import { withParams } from '@/services/withParams' test('given 2 hash, returns new Hash joined together', () => { const aHash = withParams('/foo', {}) const bHash = withParams('/bar', {}) const response = combineHash(aHash, bHash) expect(response.value).toBe('/foo/bar') }) test('given 2 hash with params, returns new Hash joined together with params', () => { const aHash = withParams('/[foz]', { foz: Boolean }) const bHash = withParams('/[baz]', { baz: Number }) const response = combineHash(aHash, bHash) expect(response.value).toBe('/[foz]/[baz]') expect(response.params).toMatchObject({ foz: { param: Boolean, isOptional: false, isGreedy: false }, baz: { param: Number, isOptional: false, isGreedy: false }, }) }) test('given 2 hash with optional params, returns new Hash joined together with params', () => { const aHash = withParams('/[?foz]', { foz: Boolean }) const bHash = withParams('/[?baz]', { baz: Number }) const response = combineHash(aHash, bHash) expect(response.value).toBe('/[?foz]/[?baz]') expect(response.params).toMatchObject({ foz: { param: Boolean, isOptional: true, isGreedy: false }, baz: { param: Number, isOptional: true, isGreedy: false }, }) }) test('given 2 hash with params that include duplicates, throws DuplicateParamsError', () => { const aHash = withParams('/[foz]', { foz: String }) const bHash = withParams('/[foz]', { foz: String }) const action: () => void = () => combineHash(aHash, bHash) expect(action).toThrow(DuplicateParamsError) }) ================================================ FILE: src/services/combineHash.ts ================================================ import { UrlPart } from '@/services/withParams' import { combinePath, CombinePath } from '@/services/combinePath' type CombineHash< TParent extends UrlPart, TChild extends UrlPart > = CombinePath export function combineHash(parentHash: TParentHash, childHash: TChildHash): CombineHash export function combineHash(parentHash: UrlPart, childHash: UrlPart): UrlPart { return combinePath(parentHash, childHash) } ================================================ FILE: src/services/combineMeta.spec.ts ================================================ import { expect, test } from 'vitest' import { MetaPropertyConflict } from '@/errors/metaPropertyConflict' import { combineMeta } from '@/services/combineMeta' test('given 2 meta objects, returns new meta joined together', () => { const aMeta = { foz: 123 } const bMeta = { baz: 'baz' } const response = combineMeta(aMeta, bMeta) expect(response).toMatchObject({ foz: 123, baz: 'baz', }) }) test('given 2 meta objects with duplicate properties but same type, overrides parent value with child', () => { const aMeta = { foz: 123 } const bMeta = { foz: 456 } const response = combineMeta(aMeta, bMeta) expect(response).toMatchObject({ foz: 456, }) }) test('given 2 meta objects with duplicate properties with DIFFERENT types, throws MetaPropertyConflict error', () => { const aMeta = { foz: 123 } const bMeta = { foz: 'baz' } const action: () => void = () => combineMeta(aMeta, bMeta) expect(action).toThrow(MetaPropertyConflict) }) ================================================ FILE: src/services/combineMeta.ts ================================================ import { MetaPropertyConflict } from '@/errors/metaPropertyConflict' export type CombineMeta< TParent extends Record, TChild extends Record > = TParent & TChild export function combineMeta, TChildMeta extends Record>(parentMeta: TParentMeta, childMeta: TChildMeta): CombineMeta export function combineMeta(parentMeta: Record, childMeta: Record): Record { checkForConflicts(parentMeta, childMeta) return { ...parentMeta, ...childMeta } } function checkForConflicts(parentMeta: Record, childMeta: Record): void { const conflict = Object.keys(parentMeta).find((key) => { return key in childMeta && typeof childMeta[key] !== typeof parentMeta[key] }) if (conflict) { throw new MetaPropertyConflict(conflict) } } ================================================ FILE: src/services/combinePath.spec-d.ts ================================================ import { expectTypeOf, test } from 'vitest' import { withParams, UrlPart } from '@/services/withParams' import { combinePath } from './combinePath' test('given withParams without params, combines value, leaves params empty', () => { const parent = withParams('/parent-without-params', {}) const child = withParams('/child-without-params', {}) const response = combinePath(parent, child) type Source = typeof response type Expect = UrlPart<{}> expectTypeOf().toEqualTypeOf() }) test('given withParams with unassigned params on parent, combines value, has single param from parent', () => { const parent = withParams('/parent-with-[param]', {}) const child = withParams('/child-without-params', {}) const response = combinePath(parent, child) type Source = typeof response type Expect = UrlPart<{ param: { param: StringConstructor, isOptional: false, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) test('given withParams with optional unassigned params on parent, combines value, has single param from parent', () => { const parent = withParams('/parent-with-[?param]', {}) const child = withParams('/child-without-params', {}) const response = combinePath(parent, child) type Source = typeof response type Expect = UrlPart<{ param: { param: StringConstructor, isOptional: true, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) test('given withParams with params on parent, combines value, has single param from parent', () => { const parent = withParams('/parent-with-[param]', { param: Boolean }) const child = withParams('/child-without-params', {}) const response = combinePath(parent, child) type Source = typeof response type Expect = UrlPart<{ param: { param: BooleanConstructor, isOptional: false, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) test('given withParams with optional params on parent, combines value, has single param from parent', () => { const parent = withParams('/parent-with-[?param]', { param: Boolean }) const child = withParams('/child-without-params', {}) const response = combinePath(parent, child) type Source = typeof response type Expect = UrlPart<{ param: { param: BooleanConstructor, isOptional: true, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) test('given withParams with params on both, combines value, has single param from parent', () => { const parent = withParams('/parent-with-[param]', { param: Boolean }) const child = withParams('/child-with-[something]', { something: Number }) const response = combinePath(parent, child) type Source = typeof response type Expect = UrlPart<{ param: { param: BooleanConstructor, isOptional: false, isGreedy: false }, something: { param: NumberConstructor, isOptional: false, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) ================================================ FILE: src/services/combinePath.spec.ts ================================================ import { expect, test } from 'vitest' import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { combinePath } from '@/services/combinePath' import { withParams } from '@/services/withParams' test('given 2 paths, returns new Path joined together', () => { const aPath = withParams('/foo', {}) const bPath = withParams('/bar', {}) const response = combinePath(aPath, bPath) expect(response.value).toBe('/foo/bar') }) test('given 2 paths with params, returns new Path joined together with params', () => { const aPath = withParams('/[foz]', { foz: Boolean }) const bPath = withParams('/[baz]', { baz: Number }) const response = combinePath(aPath, bPath) expect(response.value).toBe('/[foz]/[baz]') expect(response.params).toMatchObject({ foz: { param: Boolean, isOptional: false, isGreedy: false }, baz: { param: Number, isOptional: false, isGreedy: false }, }) }) test('given 2 paths with optional params, returns new Path joined together with params', () => { const aPath = withParams('/[?foz]', { foz: Boolean }) const bPath = withParams('/[?baz]', { baz: Number }) const response = combinePath(aPath, bPath) expect(response.value).toBe('/[?foz]/[?baz]') expect(response.params).toMatchObject({ foz: { param: Boolean, isOptional: true, isGreedy: false }, baz: { param: Number, isOptional: true, isGreedy: false }, }) }) test('given 2 paths with params that include duplicates, throws DuplicateParamsError', () => { const aPath = withParams('/[foz]', { foz: String }) const bPath = withParams('/[foz]', { foz: String }) const action: () => void = () => combinePath(aPath, bPath) expect(action).toThrow(DuplicateParamsError) }) ================================================ FILE: src/services/combinePath.ts ================================================ import { checkDuplicateParams } from '@/utilities/checkDuplicateParams' import { ToUrlPart, UrlPart, UrlParams } from '@/services/withParams' import { Identity } from '@/types/utilities' export type CombinePath< TParent extends UrlPart, TChild extends UrlPart > = ToUrlPart extends { params: infer TParentParams extends UrlParams } ? ToUrlPart extends { params: infer TChildParams extends UrlParams } ? TParentParams & TChildParams extends UrlParams ? UrlPart> : UrlPart<{}> : UrlPart<{}> : UrlPart<{}> export function combinePath(parentPath: TParentPath, childPath: TChildPath): CombinePath export function combinePath(parentPath: UrlPart, childPath: UrlPart): UrlPart { checkDuplicateParams(parentPath.params, childPath.params) const newPathString = `${parentPath.value}${childPath.value}` return { ...parentPath, value: newPathString, params: { ...parentPath.params, ...childPath.params }, } } ================================================ FILE: src/services/combineQuery.spec.ts ================================================ import { expect, test } from 'vitest' import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { combineQuery } from '@/services/combineQuery' import { withParams } from '@/services/withParams' test('given 2 queries, returns new Query joined together', () => { const aQuery = withParams('foo=ABC', {}) const bQuery = withParams('bar=123', {}) const response = combineQuery(aQuery, bQuery) expect(response.value).toBe('foo=ABC&bar=123') }) test('given 2 queries with params, returns new Query joined together with params', () => { const aQuery = withParams('foo=[foz]', { foz: Boolean }) const bQuery = withParams('bar=[baz]', { baz: Number }) const response = combineQuery(aQuery, bQuery) expect(response.value).toBe('foo=[foz]&bar=[baz]') expect(response.params).toMatchObject({ foz: { param: Boolean, isOptional: false, isGreedy: false }, baz: { param: Number, isOptional: false, isGreedy: false }, }) }) test('given 2 queries with optional params, returns new Query joined together with params', () => { const aQuery = withParams('foo=[?foz]', { foz: Boolean }) const bQuery = withParams('bar=[?baz]', { baz: Number }) const response = combineQuery(aQuery, bQuery) expect(response.value).toBe('foo=[?foz]&bar=[?baz]') expect(response.params).toMatchObject({ foz: { param: Boolean, isOptional: true, isGreedy: false }, baz: { param: Number, isOptional: true, isGreedy: false }, }) }) test('given 2 queries with params that include duplicates, throws DuplicateParamsError', () => { const aQuery = withParams('foo=[foz]', { foz: String }) const bQuery = withParams('foo=[foz]', { foz: String }) const action: () => void = () => combineQuery(aQuery, bQuery) expect(action).toThrow(DuplicateParamsError) }) ================================================ FILE: src/services/combineQuery.ts ================================================ import { checkDuplicateParams } from '@/utilities/checkDuplicateParams' import { stringHasValue } from '@/utilities/guards' import { ToUrlPart, UrlPart, UrlParams } from '@/services/withParams' import { Identity } from '@/types/utilities' type CombineQuery< TParent extends UrlPart, TChild extends UrlPart > = ToUrlPart extends { params: infer TParentParams extends UrlParams } ? ToUrlPart extends { params: infer TChildParams extends UrlParams } ? TParentParams & TChildParams extends UrlParams ? UrlPart> : UrlPart<{}> : UrlPart<{}> : UrlPart<{}> export function combineQuery(parentQuery: TParentQuery, childQuery: TChildQuery): CombineQuery export function combineQuery(parentQuery: UrlPart, childQuery: UrlPart): UrlPart { checkDuplicateParams(parentQuery.params, childQuery.params) const newQueryString = [parentQuery.value, childQuery.value] .filter(stringHasValue) .join('&') return { ...parentQuery, value: newQueryString, params: { ...parentQuery.params, ...childQuery.params }, } } ================================================ FILE: src/services/combineState.spec.ts ================================================ import { expect, test } from 'vitest' import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { combineState } from '@/services/combineState' test('given 2 states, returns new State joined together', () => { const aState = { foz: String } const bState = { baz: Number } const response = combineState(aState, bState) expect(response).toMatchObject({ foz: String, baz: Number, }) }) test('given 2 states with params that include duplicates, throws DuplicateParamsError', () => { const aState = { foz: String } const bState = { foz: Number } const action: () => void = () => combineState(aState, bState) expect(action).toThrow(DuplicateParamsError) }) ================================================ FILE: src/services/combineState.ts ================================================ import { Param } from '@/types/paramTypes' import { checkDuplicateParams } from '@/utilities/checkDuplicateParams' export type CombineState< TParent extends Record, TChild extends Record > = TParent & TChild export function combineState, TChildState extends Record>(parentState: TParentState, childState: TChildState): CombineState export function combineState(parentState: Record, childState: Record): Record { checkDuplicateParams(parentState, childState) return { ...parentState, ...childState } } ================================================ FILE: src/services/combineUrl.spec.ts ================================================ import { expect, test } from 'vitest' import { combineUrl } from '@/services/combineUrl' import { createUrl } from '@/services/createUrl' test.each([ [{ host: 'https://kitbag.dev' }, { host: 'https://kitbag.com' }], [{ host: 'https://kitbag.dev' }, { host: '' }], [{ host: 'https://kitbag.dev' }, { host: undefined }], ])('given parent host (%s) and child host (%s), returns parent host', (parent, child) => { const url = combineUrl(createUrl(parent), createUrl(child)) expect(url.stringify()).toBe('https://kitbag.dev/') }) test.each([ [{ path: '/foo' }, { path: '/bar' }], [{ path: '/foo/bar' }, { path: '' }], [{ path: '/foo/bar' }, { path: undefined }], ])('given parent path (%s) and child path (%s), returns paths combined', (parent, child) => { const url = combineUrl(createUrl(parent), createUrl(child)) expect(url.stringify()).toBe('/foo/bar') }) test.each([ [{ query: '?foo=456' }, { query: '?bar=123' }], [{ query: '?foo=456&bar=123' }, { query: '' }], [{ query: '?foo=456&bar=123' }, { query: undefined }], ])('given parent query (%s) and child query (%s), returns child query', (parent, child) => { const url = combineUrl(createUrl(parent), createUrl(child)) expect(url.stringify()).toBe('/?foo=456&bar=123') }) test.each([ [{ hash: '#foo' }, { hash: '#bar' }], [{ hash: '#foobar' }, { hash: '' }], [{ hash: '#foobar' }, { hash: undefined }], ])('given parent hash (%s) and child hash (%s), returns child hash', (parent, child) => { const url = combineUrl(createUrl(parent), createUrl(child)) expect(url.stringify()).toBe('/#foobar') }) ================================================ FILE: src/services/combineUrl.ts ================================================ import { createUrl } from '@/services/createUrl' import { combinePath } from '@/services/combinePath' import { combineQuery } from '@/services/combineQuery' import { combineHash } from '@/services/combineHash' import { toUrlPart, toUrlQueryPart } from '@/services/withParams' import { CreateUrlOptions, isUrl, ToUrl, Url, UrlInternal } from '@/types/url' import { Identity } from '@/types/utilities' export type CombineUrl< TParent extends Url, TChild extends Url > = TParent extends Url ? TChild extends Url ? Url> : never : never export function combineUrl(parent: TParent, child: TChild): CombineUrl export function combineUrl(parent: TParent, child: TChild): CombineUrl> export function combineUrl(parent: Url & UrlInternal, child: Url & UrlInternal | CreateUrlOptions): Url { if (!isUrl(parent)) { throw new Error('Parent is not a valid url') } if (!isUrl(child)) { return createUrl({ host: parent.schema.host, path: combinePath(parent.schema.path, toUrlPart(child.path)), query: combineQuery(parent.schema.query, toUrlQueryPart(child.query)), hash: combineHash(parent.schema.hash, toUrlPart(child.hash)), }) } if (!isUrl(child)) { throw new Error('Child is not a valid url') } return createUrl({ host: parent.schema.host, path: combinePath(parent.schema.path, child.schema.path), query: combineQuery(parent.schema.query, child.schema.query), hash: combineHash(parent.schema.hash, child.schema.hash), }) } ================================================ FILE: src/services/component.browser.spec.ts ================================================ import { flushPromises, mount } from '@vue/test-utils' import { expect, test } from 'vitest' import echo from '@/components/echo' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { h, defineComponent, getCurrentInstance } from 'vue' test('renders component with sync props', async () => { const route = createRoute({ name: 'echo', path: '/echo', component: echo, }, () => ({ value: 'echo' })) const router = createRouter([route], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.push('echo') expect(wrapper.html()).toBe('echo') }) test('renders component with async props', async () => { const route = createRoute({ name: 'echo', path: '/echo', component: echo, }, async () => ({ value: 'echo' })) const router = createRouter([route], { initialUrl: '/', }) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.push('echo') // needed for async props await flushPromises() expect(wrapper.html()).toBe('echo') }) test('component instance has suspense property when suspense is used', async () => { const testComponent = defineComponent({ setup() { // @ts-expect-error there isn't a way to check if suspense is used in the component without accessing a private property const hasSuspense = Boolean(getCurrentInstance()?.suspense) return () => h('span', hasSuspense) }, }) const appWithSuspenseRoot = { template: '', components: { testComponent, }, } const appWithSuspense = mount(appWithSuspenseRoot) await flushPromises() expect(appWithSuspense.text()).toBe('true') const appWithoutSuspenseRoot = { template: '', components: { testComponent, }, } const appWithoutSuspense = mount(appWithoutSuspenseRoot) expect(appWithoutSuspense.text()).toBe('false') }) test('renders component with async props using suspense', async () => { const { promise, resolve } = Promise.withResolvers<{ value: string }>() const fallback = 'Loading...' const route = createRoute({ name: 'home', path: '/', component: echo, }, () => promise) const router = createRouter([route], { initialUrl: '/', }) await router.start() const root = { template: ` `, } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.push('home') expect(wrapper.text()).toBe(fallback) resolve({ value: 'hello world' }) await flushPromises() expect(wrapper.html()).toBe('hello world') }) ================================================ FILE: src/services/component.ts ================================================ /* eslint-disable vue/require-prop-types */ /* eslint-disable vue/one-component-per-file */ import { AsyncComponentLoader, Component, FunctionalComponent, InjectionKey, defineComponent, getCurrentInstance, h, ref, watch } from 'vue' import { isPromise } from '@/utilities/promises' import { CreatedRouteOptions } from '@/types/route' import { createUsePropStore } from '@/compositions/usePropStore' import { Router } from '@/types/router' import { createUseRoute } from '@/compositions/useRoute' type Constructor = new (...args: any) => any export type ComponentProps = TComponent extends Constructor ? InstanceType['$props'] : TComponent extends AsyncComponentLoader ? ComponentProps : TComponent extends FunctionalComponent ? T : {} type CreateComponentWrapperConfig = { match: CreatedRouteOptions, name: string, component: Component, } export function createComponentPropsWrapper(routerKey: InjectionKey, { match, name, component }: CreateComponentWrapperConfig): Component { const usePropStore = createUsePropStore(routerKey) const useRoute = createUseRoute(routerKey) return defineComponent({ name: 'PropsWrapper', expose: [], setup() { const instance = getCurrentInstance() const store = usePropStore() const route = useRoute() return () => { const props = store.getProps(match.id, name, route) if (props instanceof Error) { return '' } if (isPromise(props)) { // @ts-expect-error there isn't a way to check if suspense is used in the component without accessing a private property if (instance?.suspense) { return h(SuspenseAsyncComponentPropsWrapper, { component, props }) } return h(AsyncComponentPropsWrapper, { component, props }) } return h(component, props) } }, }) } const AsyncComponentPropsWrapper = defineComponent((input: { component: Component, props: unknown }) => { const values = ref() watch(() => input.props, async (props) => { values.value = await props }, { immediate: true, deep: true }) return () => { if (values.value instanceof Error) { return '' } if (values.value) { return h(input.component, values.value) } return '' } }, { props: ['component', 'props'], }) const SuspenseAsyncComponentPropsWrapper = defineComponent(async (input: { component: Component, props: unknown }) => { const values = ref() values.value = await input.props watch(() => values.value, async (props) => { values.value = await props }, { deep: true }) return () => { if (values.value instanceof Error) { return '' } if (values.value) { return h(input.component, values.value) } return '' } }, { props: ['component', 'props'], }) ================================================ FILE: src/services/createComponentHooks.ts ================================================ import { InjectionKey, onUnmounted } from 'vue' import { createUseRouterDepth } from '@/compositions/useRouterDepth' import { createUseRouterHooks } from '@/compositions/useRouterHooks' import { AddBeforeEnterHook, AddBeforeUpdateHook, AddBeforeLeaveHook, AddAfterEnterHook, AddAfterUpdateHook, AddAfterLeaveHook, HookLifecycle, ComponentHook, HookRemove } from '@/types/hooks' import { Routes } from '@/types/route' import { Router, RouterRejections, RouterRoutes } from '@/types/router' import { Rejections } from '@/types/rejection' function createComponentHook(routerKey: InjectionKey, lifecycle: 'onBeforeRouteEnter'): AddBeforeEnterHook, RouterRejections> function createComponentHook(routerKey: InjectionKey, lifecycle: 'onBeforeRouteUpdate'): AddBeforeUpdateHook, RouterRejections> function createComponentHook(routerKey: InjectionKey, lifecycle: 'onBeforeRouteLeave'): AddBeforeLeaveHook, RouterRejections> function createComponentHook(routerKey: InjectionKey, lifecycle: 'onAfterRouteEnter'): AddAfterEnterHook, RouterRejections> function createComponentHook(routerKey: InjectionKey, lifecycle: 'onAfterRouteUpdate'): AddAfterUpdateHook, RouterRejections> function createComponentHook(routerKey: InjectionKey, lifecycle: 'onAfterRouteLeave'): AddAfterLeaveHook, RouterRejections> function createComponentHook(routerKey: symbol, lifecycle: HookLifecycle): (hook: ComponentHook) => HookRemove { const useRouterDepth = createUseRouterDepth(routerKey) const useRouterHooks = createUseRouterHooks(routerKey) return (hook: ComponentHook) => { const depth = useRouterDepth() const hooks = useRouterHooks() const remove = hooks.addComponentHook({ lifecycle, hook, depth: depth - 1 }) onUnmounted(remove) return remove } } type ComponentHooks< TRoutes extends Routes, TRejections extends Rejections > = { onBeforeRouteLeave: AddBeforeLeaveHook, onBeforeRouteUpdate: AddBeforeUpdateHook, onAfterRouteLeave: AddAfterLeaveHook, onAfterRouteUpdate: AddAfterUpdateHook, } export function createComponentHooks(routerKey: InjectionKey): ComponentHooks, RouterRejections> { const onBeforeRouteLeave = createComponentHook(routerKey, 'onBeforeRouteLeave') const onBeforeRouteUpdate = createComponentHook(routerKey, 'onBeforeRouteUpdate') const onAfterRouteLeave = createComponentHook(routerKey, 'onAfterRouteLeave') const onAfterRouteUpdate = createComponentHook(routerKey, 'onAfterRouteUpdate') return { onBeforeRouteLeave, onBeforeRouteUpdate, onAfterRouteLeave, onAfterRouteUpdate, } } ================================================ FILE: src/services/createComponentsStore.ts ================================================ import { Component, InjectionKey } from 'vue' import { createComponentPropsWrapper } from './component' import { CreatedRouteOptions } from '@/types/route' import { isWithComponent, isWithComponents } from '@/types/createRouteOptions' import { Router } from '@/types/router' import { createRouterView } from '@/components/routerView' export type ComponentsStore = { getRouteComponents: (match: CreatedRouteOptions) => Record, } export function createComponentsStore(routerKey: InjectionKey): ComponentsStore { const store = new Map>() const getRouteComponents: ComponentsStore['getRouteComponents'] = (match) => { const existing = store.get(match.id) if (existing) { return existing } const components = getAllComponentsForMatch(routerKey, match) store.set(match.id, components) return components } return { getRouteComponents, } } function getAllComponentsForMatch(routerKey: InjectionKey, options: CreatedRouteOptions): Record { const RouterView = createRouterView(routerKey) if (isWithComponents(options)) { return wrapAllComponents(routerKey, options, options.components) } if (isWithComponent(options)) { return wrapAllComponents(routerKey, options, { default: options.component }) } return { default: RouterView } } function wrapAllComponents(routerKey: InjectionKey, match: CreatedRouteOptions, components: Record): Record { return Object.fromEntries( Object.entries(components).map(([name, component]) => [name, createComponentPropsWrapper(routerKey, { match, name, component })]), ) } ================================================ FILE: src/services/createCurrentRejection.ts ================================================ import { isRejection, Rejection, RouterRejection } from '@/types/rejection' import { ref, ComputedRef, computed } from 'vue' import { ResolvedRoute } from '@/types/resolved' import { createResolvedRoute } from './createResolvedRoute' type RejectionUpdate = (rejection: Rejection) => void type RejectionClear = () => void type CurrentRejectionContext = { currentRejection: RouterRejection, currentRejectionRoute: ComputedRef, updateRejection: RejectionUpdate, clearRejection: RejectionClear, } export function createCurrentRejection(): CurrentRejectionContext { const updateRejection: RejectionUpdate = (newRejection) => { currentRejection.value = newRejection } const clearRejection: RejectionClear = () => { currentRejection.value = null } const currentRejection: RouterRejection = ref(null) const currentRejectionRoute = computed(() => { if (isRejection(currentRejection.value)) { return createResolvedRoute(currentRejection.value.route) } return null }) return { currentRejection, currentRejectionRoute, updateRejection, clearRejection, } } ================================================ FILE: src/services/createCurrentRoute.ts ================================================ import { InjectionKey, reactive } from 'vue' import { createRouterRoute } from '@/services/createRouterRoute' import { Router, RouterRouteUnion } from '@/types/router' import { ResolvedRoute } from '@/types/resolved' import { Routes } from '@/types/route' import { RouterPush } from '@/types/routerPush' type ResolvedRouteUpdate = (route: ResolvedRoute) => void type CurrentRouteContext = { currentRoute: ResolvedRoute, routerRoute: RouterRouteUnion, updateRoute: ResolvedRouteUpdate, } export function createCurrentRoute(routerKey: InjectionKey, fallbackRoute: ResolvedRoute, push: RouterPush): CurrentRouteContext export function createCurrentRoute(routerKey: InjectionKey, fallbackRoute: ResolvedRoute, push: RouterPush): CurrentRouteContext { const route = reactive({ ...fallbackRoute }) const updateRoute: ResolvedRouteUpdate = (newRoute) => { Object.assign(route, { ...newRoute, }) } const currentRoute = route const routerRoute = createRouterRoute(routerKey, currentRoute, push) return { currentRoute, routerRoute, updateRoute, } } ================================================ FILE: src/services/createExternalRoute.spec.ts ================================================ import { expect, test } from 'vitest' import { createExternalRoute } from '@/services/createExternalRoute' import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { withParams } from '@/services/withParams' test('given parent, path is combined', () => { const parent = createExternalRoute({ host: 'https://kitbag.dev', path: '/parent', }) const child = createExternalRoute({ parent: parent, path: withParams('/child/[id]', { id: Number }), }) expect(child.stringify({ id: 123 })).toBe('https://kitbag.dev/parent/child/123') }) test('given parent, query is combined', () => { const parent = createExternalRoute({ host: 'https://kitbag.dev', query: 'static=123', }) const child = createExternalRoute({ parent: parent, query: withParams('sort=[sort]', { sort: Boolean }), }) expect(child.stringify({ sort: true })).toBe('https://kitbag.dev/?static=123&sort=true') }) test('given parent and child without meta, meta matches parent', () => { const parent = createExternalRoute({ host: 'https://kitbag.dev', meta: { foo: 123, }, }) const child = createExternalRoute({ parent: parent, }) expect(child.meta).toMatchObject({ foo: 123, }) }) test.each([ ['https://[foo].dev', '/[foo]', 'foo=[zoo]', '[bar]'], ['https://[zoo].dev', '/[foo]', 'foo=[bar]', '[foo]'], ['https://[zoo].dev', '/[bar]', 'foo=[foo]', '[foo]'], ['https://[zoo].dev', '/[bar]', 'foo=[foo]', '[foo]'], ])('given duplicate params across different parts of the route, throws DuplicateParamsError', (host, path, query, hash) => { const action: () => void = () => createExternalRoute({ host, path, query, hash, }) expect(action).toThrow(DuplicateParamsError) }) ================================================ FILE: src/services/createExternalRoute.ts ================================================ import { markRaw } from 'vue' import { createRouteId } from '@/services/createRouteId' import { combineRoutes, CreateRouteOptions, isWithParent, ToRoute, WithHost, WithoutHost, WithoutParent, WithParent } from '@/types/createRouteOptions' import { toName } from '@/types/name' import { IS_ROUTE_SYMBOL, Route, RouteInternal } from '@/types/route' import { toUrlPart, toUrlQueryPart } from '@/services/withParams' import { createRouteHooks } from '@/services/createRouteHooks' import { createUrl } from '@/services/createUrl' import { createRouteRedirects } from '@/services/createRouteRedirects' import { combineUrl } from '@/services/combineUrl' import { ExternalRouteHooks } from '@/types/hooks' import { ExtractRouteContext } from '@/types/routeContext' import { RouteRedirects } from '@/types/redirects' import { createRouteTitle, RouteSetTitle } from '@/types/routeTitle' export function createExternalRoute< const TOptions extends CreateRouteOptions & WithHost & WithoutParent >(options: TOptions): ToRoute & ExternalRouteHooks, TOptions['context']> & RouteRedirects> export function createExternalRoute< const TOptions extends CreateRouteOptions & WithoutHost & WithParent >(options: TOptions): ToRoute & ExternalRouteHooks, ExtractRouteContext> & RouteRedirects> export function createExternalRoute(options: CreateRouteOptions & (WithoutHost | WithHost)): Route { const id = createRouteId() const name = toName(options.name) const path = toUrlPart(options.path) const query = toUrlQueryPart(options.query) const hash = toUrlPart(options.hash) const meta = options.meta ?? {} const host = toUrlPart(options.host) const context = options.context ?? [] const { store, redirect, ...hooks } = createRouteHooks() const { getTitle, setTitle } = createRouteTitle(options.parent) const redirects = createRouteRedirects({ getRoute: () => route, }) const rawRoute = markRaw({ id, meta: {}, state: {}, ...options }) const url = createUrl({ host, path, query, hash, }) const internal = { [IS_ROUTE_SYMBOL]: true, depth: 1, hooks: [store], getTitle, redirect, } satisfies RouteInternal const route = { id, matched: rawRoute, matches: [rawRoute], name, meta, state: {}, context, setTitle, ...hooks, ...redirects, ...url, ...internal, } satisfies Route & RouteInternal & ExternalRouteHooks & RouteRedirects & RouteSetTitle if (isWithParent(options)) { const merged = combineRoutes(options.parent, route) const url = combineUrl(options.parent, { path, query, hash, }) return { ...merged, ...url, } } return route } ================================================ FILE: src/services/createIsExternal.spec.ts ================================================ import { expect, test } from 'vitest' import { createIsExternal } from '@/services/createIsExternal' test('given undefined host, returns true', () => { const host: string | undefined = undefined const url = 'https://router.kitbag.dev/introduction.html#introduction' const isExternal = createIsExternal(host) const response = isExternal(url) expect(response).toBe(true) }) test('given host with url that matches, returns false', () => { const host = 'https://router.kitbag.dev' const url = 'https://router.kitbag.dev/introduction.html#introduction' const isExternal = createIsExternal(host) const response = isExternal(url) expect(response).toBe(false) }) test('given host with url that does NOT match, returns true', () => { const host = 'https://github.com' const url = 'https://router.kitbag.dev/introduction.html#introduction' const isExternal = createIsExternal(host) const response = isExternal(url) expect(response).toBe(true) }) ================================================ FILE: src/services/createIsExternal.ts ================================================ import { parseUrl } from '@/services/urlParser' export function createIsExternal(host: string | undefined): (url: string) => boolean { return (url: string) => { const { host: targetHost } = parseUrl(url) if (targetHost === undefined || targetHost === host) { return false } return true } } ================================================ FILE: src/services/createParam.ts ================================================ import { getParamValue, setParamValue } from '@/services/params' import { ParamWithDefault } from '@/services/withDefault' import { ExtractParamType, isParamGetSet } from '@/types/params' import { Param, ParamGetSet } from '@/types/paramTypes' export function createParam(param: TParam): TParam export function createParam(param: TParam): ParamGetSet> export function createParam(param: TParam, defaultValue: NoInfer>): ParamWithDefault export function createParam(param: TParam, defaultValue?: ExtractParamType): ParamGetSet> { if (isParamGetSet(param)) { return { ...param, defaultValue: defaultValue ?? param.defaultValue } } return { get: (value) => getParamValue(value, { param }), set: (value) => setParamValue(value, { param }), defaultValue, } } ================================================ FILE: src/services/createPropStore.ts ================================================ import { reactive } from 'vue' import { isWithComponentProps, isWithComponentPropsRecord, PropsGetter } from '@/types/createRouteOptions' import type { PrefetchConfigs, PrefetchStrategy } from '@/types/prefetch' import { getPrefetchOption } from '@/utilities/prefetch' import { ResolvedRoute } from '@/types/resolved' import { Route } from '@/types/route' import { ContextPushError } from '@/errors/contextPushError' import { ContextRejectionError } from '@/errors/contextRejectionError' import { getPropsValue } from '@/utilities/props' import { PropsCallbackParent } from '@/types/props' import { createVueAppStore, HasVueAppStore } from './createVueAppStore' import { CallbackContextPush, CallbackContextReject, CallbackContextSuccess } from '@/types/callbackContext' import { createRouterCallbackContext } from './createRouterCallbackContext' type ComponentProps = { id: string, name: string, props?: PropsGetter } type SetPropsResponse = CallbackContextSuccess | CallbackContextPush | CallbackContextReject export type PropStore = HasVueAppStore & { getPrefetchProps: (strategy: PrefetchStrategy, route: ResolvedRoute, configs: PrefetchConfigs) => Record, setPrefetchProps: (props: Record) => void, setProps: (route: ResolvedRoute) => Promise, getProps: (id: string, name: string, route: ResolvedRoute) => unknown, } export function createPropStore(): PropStore { const { setVueApp, runWithContext } = createVueAppStore() const store: Map = reactive(new Map()) const getPrefetchProps: PropStore['getPrefetchProps'] = (strategy, route, prefetch) => { const { push, replace, reject, update } = createRouterCallbackContext({ to: route }) return route.matches .filter((match) => getPrefetchOption({ ...prefetch, routePrefetch: match.prefetch }, 'props') === strategy) .flatMap((match) => getComponentProps(match)) .reduce>((response, { id, name, props }) => { if (!props) { return response } const key = getPropKey(id, name, route) const value = runWithContext(() => getPropsValue(() => props(route, { push, replace, reject, update, parent: getParentContext(route, true), }))) response[key] = value return response }, {}) } const setPrefetchProps: PropStore['setPrefetchProps'] = (props) => { Object.entries(props).forEach(([key, value]) => { store.set(key, value) }) } const setProps: PropStore['setProps'] = async (route) => { const { push, replace, reject, update } = createRouterCallbackContext({ to: route }) const componentProps = route.matches.flatMap(getComponentProps) const keys: string[] = [] const promises: Promise[] = [] for (const { id, name, props } of componentProps) { if (!props) { continue } const key = getPropKey(id, name, route) keys.push(key) if (!store.has(key)) { const value = runWithContext(() => getPropsValue(() => props(route, { push, replace, reject, update, parent: getParentContext(route), }))) store.set(key, value) } promises.push((async () => { const value = await store.get(key) if (value instanceof Error) { throw value } })()) } clearUnusedStoreEntries(keys) try { await Promise.all(promises) return { status: 'SUCCESS' } } catch (error) { if (error instanceof ContextPushError) { return error.response } if (error instanceof ContextRejectionError) { return error.response } throw error } } const getProps: PropStore['getProps'] = (id, name, route) => { const key = getPropKey(id, name, route) return store.get(key) } function getParentContext(route: ResolvedRoute, prefetch: boolean = false): PropsCallbackParent { const parent = route.matches.at(-2) if (!parent) { return } if (isWithComponentProps(parent)) { return { name: parent.name ?? '', get props() { return getParentProps(parent, 'default', route, prefetch) }, } } if (isWithComponentPropsRecord(parent)) { return { name: parent.name ?? '', props: new Proxy({}, { get(target, name) { if (typeof name !== 'string') { return Reflect.get(target, name) } return getParentProps(parent, name, route, prefetch) }, }), } } return { name: parent.name ?? '', props: undefined, } } function getParentProps(parent: Route['matched'], name: string, route: ResolvedRoute, prefetch: boolean = false): unknown { const value = getProps(parent.id, name, route) if (prefetch && !value) { const parentName = parent.name ?? 'unknown' const routeName = route.name || 'unknown' console.warn(` Unable to access parent props "${name}" from route "${parentName}" while prefetching props for route "${routeName}". This may occur if the parent route's props were not also prefetched. `) } return value } function getPropKey(id: string, name: string, route: ResolvedRoute): string { return [id, name, route.id, JSON.stringify(route.params)].join('-') } function getComponentProps(options: Route['matched']): ComponentProps[] { if (isWithComponentProps(options)) { return [ { id: options.id, name: 'default', props: options.props, }, ] } if (isWithComponentPropsRecord(options)) { return Object.entries(options.props).map(([name, props]) => ({ id: options.id, name, props })) } return [] } function clearUnusedStoreEntries(keysToKeep: string[]): void { for (const key of store.keys()) { if (keysToKeep.includes(key)) { continue } store.delete(key) } } return { getPrefetchProps, setPrefetchProps, getProps, setProps, setVueApp, } } ================================================ FILE: src/services/createRejection.ts ================================================ import { createRejectionHooks } from '@/services/createRejectionHooks' import { genericRejection } from '@/components/rejection' import { RejectionHooks } from '@/types/hooks' import { IS_REJECTION_SYMBOL, Rejection, RejectionInternal } from '@/types/rejection' import { Component, markRaw } from 'vue' import { createRoute } from '@/services/createRoute' import { RouteSetTitle } from '@/types/routeTitle' export function createRejection(options: { type: TType, component?: Component, }): Rejection & RejectionHooks & RouteSetTitle export function createRejection({ type, component }: { type: string, component?: Component }): Rejection { const { store, ...hooks } = createRejectionHooks() const route = createRoute({ name: type, component: markRaw(component ?? genericRejection(type)), }) const { setTitle } = route const internal = { [IS_REJECTION_SYMBOL]: true, route, hooks: [store], } satisfies RejectionInternal const rejection = { type, setTitle, ...hooks, ...internal, } satisfies Rejection & RejectionInternal & RejectionHooks & RouteSetTitle return rejection } ================================================ FILE: src/services/createRejectionHooks.ts ================================================ import { AddRejectionHook } from '@/types/hooks' import { Routes } from '@/types/route' import { Hooks } from '@/models/hooks' type RejectionHooks< TRoutes extends Routes = Routes, TRejections extends string = string > = { onRejection: AddRejectionHook, store: Hooks, } export function createRejectionHooks(): RejectionHooks { const store = new Hooks() const onRejection: AddRejectionHook = (hook) => { store.onRejection.add(hook) return () => store.onRejection.delete(hook) } return { onRejection, store, } } ================================================ FILE: src/services/createResolvedRoute.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { createRoute } from '@/services/createRoute' import { createResolvedRoute } from '@/services/createResolvedRoute' import { component } from '@/utilities/testHelpers' test('given a route with params returns all params', () => { const route = createRoute({ name: 'route', path: '/[paramA]', query: 'paramB=[paramB]', component, }) const response = createResolvedRoute(route, { paramA: 'A', paramB: 'B' }) expect(response.params).toMatchObject({ paramA: 'A', paramB: 'B', }) }) test('given state that matches state params, returns state', () => { const parent = createRoute({ name: 'parent', state: { foo: Boolean }, }) const child = createRoute({ parent, name: 'foo', path: '/foo', component, state: { bar: String }, }) const response = createResolvedRoute(child, {}, { state: { foo: 'true', bar: 'abc' } }) expect(response.state).toMatchObject({ foo: true, bar: 'abc' }) }) describe('resolve options', () => { test('given options with query combines with query from route', () => { const route = createRoute({ name: 'route', path: '/', query: 'foo=foo1&foo=foo2', component, }) const response = createResolvedRoute(route, {}, { query: 'bar=bar&baz' }) expect(response.query.get('foo')).toBe('foo1') expect(response.query.getAll('foo')).toMatchObject(['foo1', 'foo2']) expect(response.query.get('bar')).toBe('bar') expect(response.query.getAll('bar')).toMatchObject(['bar']) expect(response.query.get('baz')).toBe('') expect(response.query.getAll('baz')).toMatchObject(['']) expect(response.query.get('does-not-exist')).toBe(null) expect(response.query.getAll('does-not-exist')).toMatchObject([]) }) test('given options with hash replaces hash from route', () => { const route = createRoute({ name: 'route', path: '/', hash: 'bar', component, }) const response = createResolvedRoute(route, {}, { hash: 'baz' }) expect(response.hash).toBe('#baz') }) }) ================================================ FILE: src/services/createResolvedRoute.ts ================================================ import { parseUrl, updateUrl } from '@/services/urlParser' import { createResolvedRouteQuery } from '@/services/createResolvedRouteQuery' import { getStateValues } from '@/services/state' import { RouterResolveOptions } from '@/types/routerResolve' import { ResolvedRoute } from '@/types/resolved' import { isRoute, Route } from '@/types/route' export function createResolvedRoute(route: Route, params: Record = {}, options: RouterResolveOptions = {}): ResolvedRoute { const routeUrl = route.stringify(params) const href = updateUrl(routeUrl, { query: new URLSearchParams(options.query), hash: options.hash, }) const { query, hash } = parseUrl(href) const { promise: title, resolve: resolveTitle } = Promise.withResolvers() const resolvedRoute = { ...route, query: createResolvedRouteQuery(query), state: getStateValues(route.state, options.state), hash, params, href, title, } satisfies ResolvedRoute getRouteTitle(resolvedRoute).then(resolveTitle) return resolvedRoute } async function getRouteTitle(route: ResolvedRoute): Promise { if (isRoute(route)) { return route.getTitle(route) } return undefined } ================================================ FILE: src/services/createResolvedRouteQuery.ts ================================================ import { QuerySource } from '@/types/querySource' /** * Creates a dumb wrapper around URLSearchParams because URLSearchParams cannot be correctly be proxied to support writing params to the RouterRoute */ export function createResolvedRouteQuery(query?: QuerySource): URLSearchParams { const params = new URLSearchParams(query) return { get: (...args) => params.get(...args), getAll: (...args) => params.getAll(...args), set: (...args) => { params.set(...args) }, append: (...args) => { params.append(...args) }, delete: (...args) => { params.delete(...args) }, toString: (...args) => params.toString(...args), forEach: (...args) => { params.forEach(...args) }, entries: (...args) => params.entries(...args), keys: (...args) => params.keys(...args), values: (...args) => params.values(...args), has: (...args) => params.has(...args), size: params.size, sort: () => { params.sort() }, [Symbol.iterator]: () => params[Symbol.iterator](), } } ================================================ FILE: src/services/createRoute.spec-d.ts ================================================ import { describe, expectTypeOf, test } from 'vitest' import { createRoute } from './createRoute' import { Identity } from '@/types/utilities' import echo from '@/components/echo' import { component } from '@/utilities/testHelpers' import { withParams } from '@/services/withParams' import { InternalRouteHooks } from '@/types/hooks' import { BuiltInRejectionType } from '@/types/rejection' import { createRejection } from '@/services/createRejection' import { ResolvedRoute } from '@/types/resolved' import { RouteRedirects } from '@/types/redirects' import { Url } from '@/types/url' test('empty options returns an empty route', () => { const route = createRoute({}) type Source = typeof route expectTypeOf().toExtend() expectTypeOf().toMatchObjectType>() expectTypeOf().toMatchObjectType() }) test('options with name', () => { const route = createRoute({ name: 'foo' }) type Source = typeof route['name'] type Expect = 'foo' expectTypeOf().toEqualTypeOf() }) test('options with name and parent', () => { const parent = createRoute({ name: 'parent' }) const route = createRoute({ name: 'child', parent, }) type Source = typeof route['name'] type Expect = 'child' expectTypeOf().toEqualTypeOf() }) test('options with path with params', () => { const route = createRoute({ path: '/foo/[bar]' }) type Source = typeof route['params'] type Expect = { bar: { param: StringConstructor, isOptional: false, isGreedy: false } } expectTypeOf().toMatchObjectType() }) test('options with path with params and parent', () => { const parent = createRoute({ path: '/parent/[parentParam]', }) const route = createRoute({ path: '/child/[childParam]', parent, }) type Source = typeof route['params'] type Expect = { parentParam: { param: StringConstructor, isOptional: false, isGreedy: false }, childParam: { param: StringConstructor, isOptional: false, isGreedy: false }, } expectTypeOf().toMatchObjectType() }) test('options with path with params with custom param types', () => { const route = createRoute({ path: withParams('/foo/[bar]', { bar: Number }), }) type Source = typeof route['params'] type Expect = { bar: { param: NumberConstructor, isOptional: false, isGreedy: false }, } expectTypeOf().toMatchObjectType() }) test('options with path with params with custom param types and parent', () => { const parent = createRoute({ path: withParams('/parent/[parentParam]', { parentParam: Number }), }) const route = createRoute({ path: withParams('/child/[childParam]', { childParam: Boolean }), parent, }) type Source = typeof route['params'] type Expect = { parentParam: { param: NumberConstructor, isOptional: false, isGreedy: false }, childParam: { param: BooleanConstructor, isOptional: false, isGreedy: false }, } expectTypeOf().toMatchObjectType() }) test('options with query', () => { const route = createRoute({ query: 'foo=bar', }) type Source = typeof route['params'] type Expect = {} expectTypeOf().toMatchObjectType() }) test('options with query and parent', () => { const parent = createRoute({ query: 'parent=parent', }) const route = createRoute({ query: 'child=child', parent, }) type Source = typeof route['params'] type Expect = {} expectTypeOf().toMatchObjectType() }) test('options with query with params', () => { const route = createRoute({ query: 'foo=[bar]' }) type Source = typeof route['params'] type Expect = { bar: { param: StringConstructor, isOptional: false, isGreedy: false } } expectTypeOf().toMatchObjectType() }) test('options with query with params and parent', () => { const parent = createRoute({ query: 'parent=[parentParam]', }) const route = createRoute({ query: 'child=[childParam]', parent, }) type Source = typeof route['params'] type Expect = { parentParam: { param: StringConstructor, isOptional: false, isGreedy: false }, childParam: { param: StringConstructor, isOptional: false, isGreedy: false }, } expectTypeOf().toMatchObjectType() }) test('options with query with params with custom param types', () => { const route = createRoute({ query: withParams('foo=[bar]', { bar: Number }) }) type Source = typeof route['params'] type Expect = { bar: { param: NumberConstructor, isOptional: false, isGreedy: false } } expectTypeOf().toMatchObjectType() }) test('options with query with params with custom param types and parent', () => { const parent = createRoute({ query: withParams('parent=[parentParam]', { parentParam: Number }), }) const route = createRoute({ query: withParams('child=[childParam]', { childParam: Boolean }), parent, }) type Source = typeof route['params'] type Expect = { parentParam: { param: NumberConstructor, isOptional: false, isGreedy: false }, childParam: { param: BooleanConstructor, isOptional: false, isGreedy: false }, } expectTypeOf().toMatchObjectType() }) test('options with hash', () => { const route = createRoute({ hash: 'foo' }) type Source = typeof route['params'] type Expect = {} expectTypeOf().toExtend() }) test('options with hash and parent', () => { const parent = createRoute({ hash: 'parent' }) const route = createRoute({ hash: 'child', parent }) type Source = typeof route['params'] type Expect = {} expectTypeOf().toExtend() }) test('options with meta', () => { const route = createRoute({ meta: { foo: 'bar' } }) type Source = typeof route['meta'] type Expect = Readonly<{ foo: 'bar' }> expectTypeOf().toEqualTypeOf() }) test('options with meta and parent', () => { const parent = createRoute({ meta: { parent: 'parent' } }) const route = createRoute({ parent, meta: { child: 'child' } }) type Source = Identity type Expect = Readonly<{ parent: 'parent', child: 'child' }> expectTypeOf().toEqualTypeOf() }) test('options with state', () => { const route = createRoute({ state: { foo: String } }) type Source = typeof route['state'] type Expect = Readonly<{ foo: StringConstructor }> expectTypeOf().toEqualTypeOf() }) test('options with state and parent', () => { const parent = createRoute({ state: { parent: String } }) const route = createRoute({ parent, state: { child: String } }) type Source = Identity type Expect = Readonly<{ parent: StringConstructor, child: StringConstructor }> expectTypeOf().toEqualTypeOf() }) describe('props', () => { test('options with component and optional props without second argument', () => { const route = createRoute({ component, }) type Source = typeof route['matched']['props'] type Expect = undefined expectTypeOf().toEqualTypeOf() }) test('options with component and optional props with second argument', () => { const route = createRoute({ component, }, () => ({ foo: 'bar' })) type Source = typeof route['matched']['props'] type Expect = () => { foo: string } expectTypeOf().toEqualTypeOf() }) test('options with component and required props missing second argument', () => { // @ts-expect-error should require second argument const route = createRoute({ component: echo, }) type Source = typeof route['matched']['props'] type Expect = undefined expectTypeOf().toEqualTypeOf() }) test('options with component and required props with second argument ', () => { const route = createRoute({ component: echo, }, () => ({ value: 'bar', extra: true })) type Source = typeof route['matched']['props'] type Expect = () => { value: string, extra: boolean } expectTypeOf().toEqualTypeOf() }) test('options with component and required props with second argument with incorrect type', () => { const route = createRoute({ component: echo, // @ts-expect-error should not accept incorrect type }, () => ({ value: true, foo: 'bar' })) type Source = typeof route['matched']['props'] type Expect = undefined expectTypeOf().toEqualTypeOf() }) test('options with components and optional props without second argument', () => { const route = createRoute({ components: { component, }, }) type Source = typeof route['matched']['props'] type Expect = undefined expectTypeOf().toEqualTypeOf() }) test('options with components and optional props with second argument', () => { const route = createRoute({ components: { default: component, }, }, { default: () => ({ foo: 'bar' }), }) type Source = typeof route['matched']['props'] type Expect = { default: () => { foo: string } } expectTypeOf().toEqualTypeOf() }) test('options with components and required props missing second argument', () => { // @ts-expect-error should require second argument const route = createRoute({ components: { default: echo, }, }) type Source = typeof route['matched']['props'] type Expect = undefined expectTypeOf().toEqualTypeOf() }) test('options with components and required props with second argument ', () => { const route = createRoute({ components: { default: echo, }, }, { default: () => ({ value: 'bar', extra: true }), }) type Source = typeof route['matched']['props'] type Expect = { default: () => { value: string, extra: boolean } } expectTypeOf().toEqualTypeOf() }) test('options with components and required props with second argument with incorrect type', () => { const route = createRoute({ components: { default: echo, }, }, { // @ts-expect-error should not accept incorrect type default: () => ({ value: true, foo: 'bar' }), }) type Source = typeof route['matched']['props'] type Expect = undefined expectTypeOf().toEqualTypeOf() }) test('undefined is not a valid value for 2nd argument of createRoute', () => { const route = createRoute({ component: echo, // @ts-expect-error should not accept undefined }, undefined) type Source = typeof route['matched']['props'] type Expect = undefined expectTypeOf().toEqualTypeOf() }) test('parent props are undefined when parent has no props', () => { const parent = createRoute({ name: 'parent', }) createRoute({ name: 'child', parent: parent, }, (__, { parent }) => { expectTypeOf(parent.props).toEqualTypeOf() expectTypeOf(parent.name).toEqualTypeOf<'parent'>() return {} }) }) test('sync parent props are passed to child props', () => { const parent = createRoute({ name: 'parent', }, () => ({ foo: 123 })) createRoute({ name: 'child', parent: parent, }, (__, { parent }) => { expectTypeOf(parent.props).toEqualTypeOf<{ foo: number }>() expectTypeOf(parent.name).toEqualTypeOf<'parent'>() return {} }) }) test('async parent props are passed to child props', () => { const parent = createRoute({ name: 'parent', }, async () => ({ foo: 123 })) createRoute({ name: 'child', parent: parent, }, (__, { parent }) => { expectTypeOf(parent.props).toEqualTypeOf>() expectTypeOf(parent.name).toEqualTypeOf<'parent'>() return {} }) }) test('parent props are passed to child props when multiple parent components are used', () => { const parent = createRoute({ name: 'parent', components: { one: component, two: component, }, }, { one: () => ({ foo: 123 }), two: async () => ({ foo: 456 }), }) createRoute({ name: 'child', parent: parent, }, (__, { parent }) => { expectTypeOf(parent.props).toEqualTypeOf<{ one: { foo: number }, two: Promise<{ foo: number }>, }>() expectTypeOf(parent.name).toEqualTypeOf<'parent'>() return {} }) }) test('parent props are passed to child props when multiple child components are used', () => { const parent = createRoute({ name: 'parent', }, async () => ({ foo: 123 })) createRoute({ name: 'child', parent: parent, components: { one: component, two: component, }, }, { one: (__, { parent }) => { expectTypeOf(parent.props).toEqualTypeOf>() expectTypeOf(parent.name).toEqualTypeOf<'parent'>() return {} }, two: (__, { parent }) => { expectTypeOf(parent.props).toEqualTypeOf>() expectTypeOf(parent.name).toEqualTypeOf<'parent'>() return {} }, }) }) describe('reject', () => { test('accepts built in rejections when no context is provided', () => { createRoute({ name: 'route', component: echo, }, (__, context) => { type Source = Parameters[0] type Expect = BuiltInRejectionType expectTypeOf().toEqualTypeOf() return { value: 'foo' } }) }) test('accepts built in rejections and custom rejections when context is provided', () => { const rejection = createRejection({ type: 'NotAuthorized', }) createRoute({ name: 'route', component: echo, context: [rejection], }, (__, context) => { type Source = Parameters[0] type Expect = 'NotAuthorized' | BuiltInRejectionType expectTypeOf().toEqualTypeOf() return { value: 'foo' } }) }) }) describe('push', () => { test('accepts url when no context is provided', () => { createRoute({ name: 'route', component: echo, }, (__, context) => { // should accept a url context.push('/') // @ts-expect-error should not accept an invalid url context.push('foo') return { value: 'foo' } }) }) test('accepts current route when no context is provided', () => { createRoute({ name: 'route', path: '/[paramName]', }, (__, context) => { context.push('route', { paramName: 'value' }) return {} }) }) test('accepts url and routes when context is provided', () => { const route = createRoute({ name: 'contextRoute', }) createRoute({ name: 'route', component: echo, context: [route], }, (__, context) => { // valid context.push('route') // valid context.push('contextRoute') // @ts-expect-error should not accept an invalid route name context.push('foo') // should accept a url context.push('/') return { value: 'foo' } }) }) }) describe('replace', () => { test('accepts url when no context is provided', () => { createRoute({ name: 'route', component: echo, }, (__, context) => { // should accept a url context.replace('/') // @ts-expect-error should not accept an invalid url context.replace('foo') return { value: 'foo' } }) }) test('accepts url and routes when context is provided', () => { const route = createRoute({ name: 'route', }) createRoute({ name: 'route', component: echo, context: [route], }, (__, context) => { // valid context.replace('route') // @ts-expect-error should not accept an invalid route name context.replace('foo') // should accept a url context.replace('/') return { value: 'foo' } }) }) }) describe('update', () => { test('accepts params based on the current route', () => { createRoute({ name: 'route', path: '/[paramName]', component, }, (__, context) => { context.update('paramName', 'value') // @ts-expect-error should not accept invalid param name context.update('invalidParamName', 'value') context.update({ paramName: 'value' }) // @ts-expect-error should not accept invalid params context.update({ invalidParamName: 'value' }) context.update({ paramName: 'value' }, { replace: true }) // @ts-expect-error should not accept invalid options context.update({ paramName: 'value' }, { invalid: true }) return {} }) }) }) }) describe('meta', () => { test('is always defined', () => { const route = createRoute({ name: 'route', }) expectTypeOf(route.meta).toEqualTypeOf>() }) test('preserves provided values', () => { const route = createRoute({ name: 'route', meta: { foo: 'bar', }, }) expectTypeOf(route.meta).toEqualTypeOf>() }) test('preserves provided values with parent', () => { const parent = createRoute({ name: 'parent', meta: { foo: 'bar', }, }) const route = createRoute({ name: 'child', parent: parent, meta: { bar: 'baz', }, }) expectTypeOf(parent.meta).toExtend<{}>() expectTypeOf(route.meta.bar).toEqualTypeOf<'baz'>() expectTypeOf(route.meta.foo).toEqualTypeOf<'bar'>() }) }) describe('matched.meta', () => { test('is always defined', () => { const route = createRoute({ name: 'route', }) expectTypeOf(route.matched.meta).toEqualTypeOf>() }) test('preserves provided values', () => { const route = createRoute({ name: 'route', meta: { foo: 'bar', }, }) expectTypeOf(route.matched.meta).toEqualTypeOf>() }) }) describe('matches[number].meta', () => { test('is always defined', () => { const route = createRoute({ name: 'route', }) expectTypeOf(route.matches[0].meta).toEqualTypeOf>() }) test('preserves provided values', () => { const route = createRoute({ name: 'route', meta: { foo: 'bar', }, }) expectTypeOf(route.matches[0].meta).toEqualTypeOf>() }) }) describe('hooks', () => { test('to and from are typed correctly', () => { const route = createRoute({ name: 'route', path: '/[paramName]', component, }) route.onBeforeRouteEnter((to, { from }) => { expectTypeOf(to).toEqualTypeOf>() expectTypeOf(from).toEqualTypeOf() }) route.onBeforeRouteUpdate((to, { from }) => { expectTypeOf(to).toEqualTypeOf>() expectTypeOf(from).toEqualTypeOf() }) route.onBeforeRouteLeave((to, { from }) => { expectTypeOf(to).toEqualTypeOf() expectTypeOf(from).toEqualTypeOf>() }) route.onAfterRouteEnter((to, { from }) => { expectTypeOf(to).toEqualTypeOf>() expectTypeOf(from).toEqualTypeOf() }) route.onAfterRouteUpdate((to, { from }) => { expectTypeOf(to).toEqualTypeOf>() expectTypeOf(from).toEqualTypeOf() }) route.onAfterRouteLeave((to, { from }) => { expectTypeOf(to).toEqualTypeOf() expectTypeOf(from).toEqualTypeOf>() }) }) test('context.push', () => { const contextRoute = createRoute({ name: 'contextRoute', component, }) const route = createRoute({ context: [contextRoute], name: 'route', path: '/[paramName]', component, }) route.onBeforeRouteEnter((_to, context) => { // valid - current route context.push('route', { paramName: 'value' }) // valid - context route context.push('contextRoute') // @ts-expect-error should not accept an invalid route name context.push('foo') // @ts-expect-error should not accept an invalid param context.push('route', { invalidParamName: 'value' }) }) }) test('context.update', () => { const route = createRoute({ name: 'route', path: '/[paramName]', component, }) route.onBeforeRouteEnter((_to, context) => { context.update('paramName', 'value') // @ts-expect-error should not accept invalid param name context.update('invalidParamName', 'value') context.update({ paramName: 'value' }) // @ts-expect-error should not accept invalid params context.update({ invalidParamName: 'value' }) context.update({ paramName: 'value' }, { replace: true }) // @ts-expect-error should not accept invalid options context.update({ paramName: 'value' }, { invalid: true }) }) }) }) test('given parent, context is combined', () => { const parentRejection = createRejection({ type: 'aRejection' }) const childRelated = createRoute({ name: 'bRoute' }) const parent = createRoute({ meta: { foo: 123, }, context: [parentRejection], }) const child = createRoute({ parent, context: [childRelated], meta: { bar: 'zoo', }, }) child.onAfterRouteEnter((_to, { push, reject }) => { expectTypeOf(reject).parameters.toEqualTypeOf<['NotFound' | 'aRejection']>() // ok push('bRoute') // @ts-expect-error should not accept an invalid route name push('fakeRoute') }) }) ================================================ FILE: src/services/createRoute.spec.ts ================================================ import { describe, expect, test, vi } from 'vitest' import { createRoute } from '@/services/createRoute' import { component } from '@/utilities/testHelpers' import { createRouter } from '@/services/createRouter' import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { withParams } from '@/services/withParams' import { createRejection } from './createRejection' describe('combine', () => { test('given parent, path is combined', () => { const parent = createRoute({ path: '/parent', }) const child = createRoute({ parent: parent, path: withParams('/child/[id]', { id: Number }), }) expect(child.stringify({ id: 123 })).toBe('/parent/child/123') }) test('given undefined path, path is combined', () => { const parent = createRoute({ path: '/parent', }) const child = createRoute({ parent: parent, }) const grandChild = createRoute({ parent: child, path: '/grand-child', }) const kinless = createRoute({}) expect(kinless.stringify()).toBe('/') expect(child.stringify()).toBe('/parent') expect(grandChild.stringify()).toBe('/parent/grand-child') }) test('given parent, query is combined', () => { const parent = createRoute({ query: 'static=123', }) const child = createRoute({ parent: parent, query: withParams('sort=[sort]', { sort: Boolean }), }) expect(child.stringify({ sort: true })).toBe('/?static=123&sort=true') }) test('given parent, state is combined into state', () => { const parent = createRoute({ state: { foo: Number, }, }) const child = createRoute({ parent: parent, state: { bar: String, }, }) expect(child.state).toMatchObject({ foo: Number, bar: String, }) }) test('given parent and child without state, state matches parent', () => { const parent = createRoute({ state: { foo: Number, }, }) const child = createRoute({ parent: parent, }) expect(child.state).toMatchObject({ foo: Number, }) }) test('given parent, meta is combined', () => { const parent = createRoute({ meta: { foo: 123, }, }) const child = createRoute({ parent: parent, meta: { bar: 'zoo', }, }) expect(child.meta).toMatchObject({ foo: 123, bar: 'zoo', }) }) test('given parent, context is combined', () => { const parentRejection = createRejection({ type: 'aRejection' }) const childRelated = createRoute({ name: 'bRoute' }) const parent = createRoute({ meta: { foo: 123, }, context: [parentRejection], }) const child = createRoute({ parent, context: [childRelated], meta: { bar: 'zoo', }, }) expect(child.context).toMatchObject([parentRejection, childRelated]) }) test('given parent and child without meta, meta matches parent', () => { const parent = createRoute({ meta: { foo: 123, }, }) const child = createRoute({ parent: parent, }) expect(child.meta).toMatchObject({ foo: 123, }) }) test('given child has hoist, everything is combined except url', () => { const parent = createRoute({ path: '/parent/[?parent]', query: 'parent=123', hash: 'parent', state: { parent: 'parent', }, meta: { parent: 'parent', }, }) const child = createRoute({ parent, hoist: true, path: '/child/[?child]', query: 'child=456', hash: 'child', state: { child: 'child', }, meta: { child: 'child', }, }) const params = child.parse('/child/42?child=456#child') expect(params).toMatchObject({ child: '42', }) // @ts-expect-error - parent is not a param params.parent = true expect(child.stringify({ child: '42' })).toBe('/child/42?child=456#child') expect(child.state).toMatchObject({ parent: 'parent', child: 'child', }) expect(child.meta).toMatchObject({ parent: 'parent', child: 'child', }) }) }) describe('props', () => { test('parent context is passed to child props', async () => { const spy = vi.fn() const parent = createRoute({ name: 'parent', }) const child = createRoute({ name: 'child', parent: parent, path: '/child', }, (_, { parent }) => { return spy(parent) }) const router = createRouter([parent, child], { initialUrl: '/child', }) await router.start() expect(spy).toHaveBeenCalledWith({ name: 'parent', props: undefined }) }) test('sync parent props are passed to child props', async () => { const spy = vi.fn() const parent = createRoute({ name: 'parent', }, () => ({ foo: 123 })) const child = createRoute({ name: 'child', parent: parent, path: '/child', }, (__, { parent }) => { return spy({ value: parent.props.foo }) }) const router = createRouter([parent, child], { initialUrl: '/child', }) await router.start() expect(spy).toHaveBeenCalledWith({ value: 123 }) }) test('async parent props are passed to child props', async () => { const spy = vi.fn() const parent = createRoute({ name: 'parent', }, async () => ({ foo: 123 })) const child = createRoute({ name: 'child', parent: parent, path: '/child', }, async (__, { parent }) => { expect(parent.props).toBeDefined() expect(parent.props).toBeInstanceOf(Promise) const { foo: value } = await parent.props return spy({ value }) }) const router = createRouter([parent, child], { initialUrl: '/child', }) await router.start() expect(spy).toHaveBeenCalledWith({ value: 123 }) }) test('sync parent props with multiple views are passed to child props', async () => { const spy = vi.fn() const parent = createRoute({ name: 'parent', components: { one: component, two: component, three: component, }, }, { one: () => ({ foo: 123 }), two: () => ({ bar: 456 }), }) const child = createRoute({ name: 'child', parent: parent, path: '/child', }, (__, { parent }) => { return spy({ value1: parent.props.one.foo, value2: parent.props.two.bar, }) }) const router = createRouter([parent, child], { initialUrl: '/child', }) await router.start() expect(spy).toHaveBeenCalledWith({ value1: 123, value2: 456 }) }) test('async parent props with multiple views are passed to child props', async () => { const spy = vi.fn() const parent = createRoute({ name: 'parent', components: { one: component, two: component, three: component, }, }, { one: async () => ({ foo: 123 }), two: async () => ({ bar: 456 }), }) const child = createRoute({ name: 'child', parent: parent, path: '/child', }, async (__, { parent }) => { expect(parent.props).toBeDefined() expect(parent.props.one).toBeInstanceOf(Promise) expect(parent.props.two).toBeInstanceOf(Promise) const { foo: value1 } = await parent.props.one const { bar: value2 } = await parent.props.two return spy({ value1, value2, }) }) const router = createRouter([parent, child], { initialUrl: '/child', }) await router.start() expect(spy).toHaveBeenCalledWith({ value1: 123, value2: 456 }) }) }) test.each([ ['/[foo]', 'foo=[foo]', '[bar]'], ['/[foo]', 'foo=[bar]', '[foo]'], ['/[bar]', 'foo=[foo]', '[foo]'], ])('given duplicate params across different parts of the route, throws DuplicateParamsError', (path, query, hash) => { const action: () => void = () => createRoute({ path, query, hash, }) expect(action).toThrow(DuplicateParamsError) }) ================================================ FILE: src/services/createRoute.ts ================================================ import { markRaw } from 'vue' import { createRouteId } from '@/services/createRouteId' import { CreateRouteOptions, PropsGetter, CreateRouteProps, ToRoute, combineRoutes, isWithParent, RouterViewPropsGetter } from '@/types/createRouteOptions' import { toName } from '@/types/name' import { IS_ROUTE_SYMBOL, Route, RouteInternal } from '@/types/route' import { createRouteHooks } from '@/services/createRouteHooks' import { toUrlPart, toUrlQueryPart } from '@/services/withParams' import { createUrl } from '@/services/createUrl' import { createRouteRedirects } from '@/services/createRouteRedirects' import { combineUrl } from '@/services/combineUrl' import { InternalRouteHooks } from '@/types/hooks' import { ExtractRouteContext } from '@/types/routeContext' import { RouteRedirects } from '@/types/redirects' import { createRouteTitle, RouteSetTitle } from '@/types/routeTitle' type CreateRouteWithProps< TOptions extends CreateRouteOptions, TProps extends CreateRouteProps > = CreateRouteProps extends RouterViewPropsGetter ? [ props?: RouterViewPropsGetter ] : CreateRouteProps extends PropsGetter ? Partial>> extends ReturnType> ? [ props?: TProps ] : [ props: TProps ] : Partial> extends CreateRouteProps ? [ props?: TProps ] : [ props: TProps ] export function createRoute< const TOptions extends CreateRouteOptions, const TProps extends CreateRouteProps >(options: TOptions, ...args: CreateRouteWithProps): ToRoute & InternalRouteHooks, ExtractRouteContext> & RouteRedirects> & RouteSetTitle> export function createRoute(options: CreateRouteOptions, props?: CreateRouteProps): Route { const id = createRouteId() const name = toName(options.name) const path = toUrlPart(options.path) const query = toUrlQueryPart(options.query) const hash = toUrlPart(options.hash) const meta = options.meta ?? {} const state = options.state ?? {} const context = options.context ?? [] const { store, redirect, ...hooks } = createRouteHooks() const { setTitle, getTitle } = createRouteTitle(options.parent) const rawRoute = markRaw({ ...options, id, meta, state, props, name }) const redirects = createRouteRedirects({ getRoute: () => route, }) const url = createUrl({ path, query, hash, }) const internal = { [IS_ROUTE_SYMBOL]: true, depth: 1, hooks: [store], getTitle, redirect, } satisfies RouteInternal const route = { id, matched: rawRoute, matches: [rawRoute], name, meta, state, context, prefetch: options.prefetch, setTitle, ...redirects, ...url, ...hooks, ...internal, } satisfies Route & RouteInternal & InternalRouteHooks & RouteRedirects & RouteSetTitle if (isWithParent(options)) { const merged = combineRoutes(options.parent, route) if (options.hoist) { return merged } const url = combineUrl(options.parent, { path, query, hash, }) return { ...merged, ...url, } } return route } ================================================ FILE: src/services/createRouteHooks.ts ================================================ import { AddBeforeEnterHook, AddBeforeUpdateHook, AddBeforeLeaveHook, AddAfterEnterHook, AddAfterUpdateHook, AddAfterLeaveHook, AddErrorHook, AddRejectionHook } from '@/types/hooks' import { Routes } from '@/types/route' import { Hooks } from '@/models/hooks' import { ExtractRejectionTypes, Rejection } from '@/types/rejection' import { RedirectHook, RouteRedirect } from '@/types/redirects' import { MultipleRouteRedirectsError } from '@/errors/multipleRouteRedirectsError' type RouteHooks< TRoutes extends Routes = Routes, TRejections extends Rejection[] = Rejection[] > = { redirect: RouteRedirect, onBeforeRouteEnter: AddBeforeEnterHook, onBeforeRouteUpdate: AddBeforeUpdateHook, onBeforeRouteLeave: AddBeforeLeaveHook, onAfterRouteEnter: AddAfterEnterHook, onAfterRouteUpdate: AddAfterUpdateHook, onAfterRouteLeave: AddAfterLeaveHook, onError: AddErrorHook, onRejection: AddRejectionHook, TRoutes>, store: Hooks, } export function createRouteHooks(): RouteHooks { const store = new Hooks() const redirect: RouteRedirect = (to, convertParams) => { if (store.redirects.size > 0) { throw new MultipleRouteRedirectsError(to.name) } const hook: RedirectHook = (from, { replace }) => { replace(to.name, convertParams?.(from.params)) } store.redirects.add(hook) return () => store.redirects.delete(hook) } const onBeforeRouteEnter: AddBeforeEnterHook = (hook) => { store.onBeforeRouteEnter.add(hook) return () => store.onBeforeRouteEnter.delete(hook) } const onBeforeRouteUpdate: AddBeforeUpdateHook = (hook) => { store.onBeforeRouteUpdate.add(hook) return () => store.onBeforeRouteUpdate.delete(hook) } const onBeforeRouteLeave: AddBeforeLeaveHook = (hook) => { store.onBeforeRouteLeave.add(hook) return () => store.onBeforeRouteLeave.delete(hook) } const onAfterRouteEnter: AddAfterEnterHook = (hook) => { store.onAfterRouteEnter.add(hook) return () => store.onAfterRouteEnter.delete(hook) } const onAfterRouteUpdate: AddAfterUpdateHook = (hook) => { store.onAfterRouteUpdate.add(hook) return () => store.onAfterRouteUpdate.delete(hook) } const onAfterRouteLeave: AddAfterLeaveHook = (hook) => { store.onAfterRouteLeave.add(hook) return () => store.onAfterRouteLeave.delete(hook) } const onError: AddErrorHook = (hook) => { store.onError.add(hook) return () => store.onError.delete(hook) } const onRejection: AddRejectionHook = (hook) => { store.onRejection.add(hook) return () => store.onRejection.delete(hook) } return { redirect, onBeforeRouteEnter, onBeforeRouteUpdate, onBeforeRouteLeave, onAfterRouteEnter, onAfterRouteUpdate, onAfterRouteLeave, onError, onRejection, store, } } ================================================ FILE: src/services/createRouteId.ts ================================================ import { createUniqueIdSequence } from './createUniqueIdSequence' export const createRouteId = createUniqueIdSequence() ================================================ FILE: src/services/createRouteRedirects.spec.ts ================================================ import { MultipleRouteRedirectsError } from '@/errors/multipleRouteRedirectsError' import { createRoute, createRouter } from '@/main' import { expect, test } from 'vitest' test('redirectTo correctly redirects to the to route', async () => { const to = createRoute({ name: 'to', path: '/to', }) const from = createRoute({ name: 'from', path: '/from', }) from.redirectTo(to) const router = createRouter([to, from], { initialUrl: '/from' }) await router.start() expect(router.route.href).toBe('/to') }) test('redirectFrom correctly redirects from the from route', async () => { const to = createRoute({ name: 'to', path: '/to', }) const from = createRoute({ name: 'from', path: '/from', }) to.redirectFrom(from) const router = createRouter([to, from], { initialUrl: '/from' }) await router.start() expect(router.route.href).toBe('/to') }) test('redirectTo correctly redirects to the to route with params', async () => { const paramValue = 'paramValue' const to = createRoute({ name: 'to', path: '/to/[toParam]', }) const from = createRoute({ name: 'from', path: '/from/[fromParam]', }) to.redirectFrom(from, ({ fromParam }) => { expect(fromParam).toEqual(paramValue) return { toParam: fromParam } }) const router = createRouter([to, from], { initialUrl: `/from/${paramValue}`, }) await router.start() expect(router.route.href).toBe(`/to/${paramValue}`) }) test('redirectFrom correctly redirects from the from route with params', async () => { const paramValue = 'paramValue' const to = createRoute({ name: 'to', path: '/to/[toParam]', }) const from = createRoute({ name: 'from', path: '/from/[fromParam]', }) from.redirectTo(to, ({ fromParam }) => { expect(fromParam).toEqual(paramValue) return { toParam: fromParam } }) const router = createRouter([to, from], { initialUrl: `/from/${paramValue}`, }) await router.start() expect(router.route.href).toBe(`/to/${paramValue}`) }) test('throws MultipleRouteRedirectsError when a route has multiple redirects', () => { const to = createRoute({ name: 'to', path: '/to', }) const from = createRoute({ name: 'from', path: '/from', }) to.redirectFrom(from) expect(() => to.redirectFrom(from)).toThrow(MultipleRouteRedirectsError) expect(() => from.redirectTo(to)).toThrow(MultipleRouteRedirectsError) }) test('from does not need to be passed into the route for redirect to work', async () => { const to = createRoute({ name: 'to', path: '/to', }) const from = createRoute({ name: 'from', path: '/from', }) to.redirectFrom(from) const router = createRouter([to], { initialUrl: '/from' }) await router.start() expect(router.route.href).toBe('/to') }) test('to does not need to be passed into the route for redirect to work', async () => { const to = createRoute({ name: 'to', path: '/to', }) const from = createRoute({ name: 'from', path: '/from', }) to.redirectTo(from) const router = createRouter([from], { initialUrl: '/to' }) await router.start() expect(router.route.href).toBe('/from') }) ================================================ FILE: src/services/createRouteRedirects.ts ================================================ import { InvalidRouteRedirectError } from '@/errors/invalidRouteRedirectError' import { RouteRedirects, RouteRedirectFrom, RouteRedirectTo, RedirectToArgs } from '@/types/redirects' import { isRoute, Route } from '@/types/route' type CreateRouteRedirectsContext = { /** * The to route for the redirectFrom callback and the from route for the redirectTo callback. */ getRoute: () => Route, } export function createRouteRedirects({ getRoute }: CreateRouteRedirectsContext): RouteRedirects { const redirectTo: RouteRedirectTo = (...[to, convertParams]: RedirectToArgs) => { const from = getRoute() if (!isRoute(from)) { throw new InvalidRouteRedirectError(from.name) } to.context.push(from) from.context.push(to) from.redirect(to, convertParams) } const redirectFrom: RouteRedirectFrom = (from, convertParams) => { const to = getRoute() if (!isRoute(from)) { throw new InvalidRouteRedirectError(from.name) } to.context.push(from) from.context.push(to) from.redirect(to, convertParams) } return { redirectTo, redirectFrom, } } ================================================ FILE: src/services/createRouter.browser.spec.ts ================================================ import { flushPromises, mount } from '@vue/test-utils' import { describe, expect, test } from 'vitest' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { component } from '@/utilities/testHelpers' import { createRejection } from './createRejection' test('Router is automatically started when installed', async () => { const route = createRoute({ name: 'root', path: '/', component, }) const router = createRouter([route], { initialUrl: '/', }) const root = { template: '', } expect(router.route.name).toBe('NotFound') mount(root, { global: { plugins: [router], }, }) await router.start() expect(router.route.name).toBe('root') }) describe('options.rejections', () => { test('given a rejection, adds the rejection to the router', async () => { const route = createRoute({ name: 'root', path: '/', component, }) const customRejection = createRejection({ type: 'CustomRejection', component: { template: '
This is a custom rejection
' }, }) const router = createRouter([route], { initialUrl: '/', rejections: [customRejection], }) const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.start() expect(router.route.name).toBe('root') router.reject('CustomRejection') await flushPromises() expect(router.route.name).toBe('root') expect(window.location.pathname).toBe('/') expect(wrapper.html()).toBe('
This is a custom rejection
') }) }) test('given child has hoist, keeps parent context and components without parent url', async () => { const parent = createRoute({ name: 'parent', path: '/parent', component: { template: '
' }, }) const child = createRoute({ name: 'child', parent, hoist: true, path: '/child/[?child]', component: { template: '' }, }) const router = createRouter([child], { initialUrl: '/' }) const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.start() await router.push('child', { child: '42' }) expect(router.route).toMatchObject(expect.objectContaining({ name: 'child', href: '/child/42', })) expect(wrapper.html()).toBe('
') }) ================================================ FILE: src/services/createRouter.spec-d.ts ================================================ import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { component } from '@/utilities/testHelpers' import { describe, test, expectTypeOf } from 'vitest' import { createRouterPlugin } from './createRouterPlugin' import { BuiltInRejectionType } from '@/types/rejection' import { createRejection } from './createRejection' import { AddBeforeEnterHook, AddBeforeUpdateHook, AddBeforeLeaveHook, AddAfterEnterHook, AddAfterUpdateHook, AddAfterLeaveHook, AddErrorHook, AddRejectionHook } from '@/types/hooks' import { RouterAbort } from '@/types/routerAbort' import { RouteUpdate } from '@/types/routeUpdate' import { ResolvedRouteUnion } from '@/types/resolved' import { RouterRouteUnion } from '@/types/router' describe('hooks', () => { const parent = createRoute({ name: 'parent', path: '/parent/[parentParam]', component, }) const child = createRoute({ name: 'child', path: '/child/[childParam]', parent, component, }) const pluginRoute = createRoute({ name: 'plugin', path: '/plugin/[pluginParam]', component, }) const routes = [parent, child] as const const pluginRoutes = [pluginRoute] as const const plugin = createRouterPlugin({ routes: pluginRoutes, }) const router = createRouter(routes, { initialUrl: '/' }, [plugin]) type Routes = typeof routes | typeof pluginRoutes test('functions are correctly typed', () => { expectTypeOf(router.onBeforeRouteEnter).toEqualTypeOf>() expectTypeOf(router.onBeforeRouteLeave).toEqualTypeOf>() expectTypeOf(router.onBeforeRouteUpdate).toEqualTypeOf>() expectTypeOf(router.onAfterRouteEnter).toEqualTypeOf>() expectTypeOf(router.onAfterRouteLeave).toEqualTypeOf>() expectTypeOf(router.onAfterRouteUpdate).toEqualTypeOf>() expectTypeOf(router.onError).toEqualTypeOf>() expectTypeOf(router.onRejection).toEqualTypeOf>() }) test('to and from can be narrowed', () => { router.onBeforeRouteEnter((to, context) => { if (to.name === 'parent') { expectTypeOf(to.name).toEqualTypeOf<'parent'>() expectTypeOf(to.params).toEqualTypeOf<{ parentParam: string }>() } if (context.from?.name === 'parent') { expectTypeOf(context.from.name).toEqualTypeOf<'parent'>() expectTypeOf(context.from.params).toEqualTypeOf<{ parentParam: string }>() } if (to.name === 'child') { expectTypeOf(to.name).toEqualTypeOf<'child'>() expectTypeOf(to.params).toEqualTypeOf<{ parentParam: string, childParam: string }>() } if (context.from?.name === 'child') { expectTypeOf(context.from.name).toEqualTypeOf<'child'>() expectTypeOf(context.from.params).toEqualTypeOf<{ parentParam: string, childParam: string }>() } expectTypeOf(context.push).toEqualTypeOf(router.push) expectTypeOf(context.replace).toEqualTypeOf(router.replace) }) }) test('context.push', () => { router.onBeforeRouteEnter((_to, context) => { expectTypeOf(context.push).toEqualTypeOf(router.push) }) }) test('context.replace', () => { router.onBeforeRouteEnter((_to, context) => { expectTypeOf(context.replace).toEqualTypeOf(router.replace) }) }) test('context.reject', () => { router.onBeforeRouteEnter((_to, context) => { expectTypeOf(context.reject).toEqualTypeOf(router.reject) }) }) test('context.update', () => { router.onBeforeRouteEnter((_to, context) => { expectTypeOf(context.update).toEqualTypeOf>>() }) }) test('context.abort', () => { router.onBeforeRouteEnter((_to, context) => { expectTypeOf(context.abort).toEqualTypeOf() }) }) }) describe('rejections', () => { test('built in rejections are valid', () => { const _router = createRouter([]) type Source = Parameters[0] type Expect = BuiltInRejectionType expectTypeOf().toEqualTypeOf() }) test('custom rejections are valid', () => { const myCustomRejection = createRejection({ type: 'MyCustomRejection', component, }) const _router = createRouter([], { rejections: [myCustomRejection], }) type Source = Parameters[0] type Expect = BuiltInRejectionType | 'MyCustomRejection' expectTypeOf().toEqualTypeOf() }) test('custom rejections from plugins are valid', () => { const myPluginRejection = createRejection({ type: 'MyPluginRejection', component, }) const plugin = createRouterPlugin({ rejections: [myPluginRejection], }) const _router = createRouter([], {}, [plugin]) type Source = Parameters[0] type Expect = BuiltInRejectionType | 'MyPluginRejection' expectTypeOf().toEqualTypeOf() }) }) describe('options.rejections in hooks', () => { test('route hooks do not have access to router-level rejections', () => { const route = createRoute({ name: 'root', path: '/', component, }) const customRejection = createRejection({ type: 'CustomRejection', component: { template: '
This is a custom rejection
' }, }) createRouter([route], { initialUrl: '/', rejections: [customRejection], }) route.onBeforeRouteUpdate((_to, { reject, push }) => { expectTypeOf(reject).parameters.toEqualTypeOf<[BuiltInRejectionType]>() // ok push('root') // @ts-expect-error does not know about routes outside of context push('fakeRoute') }) }) test('router hooks have access to router-level rejections', () => { const route = createRoute({ name: 'root', path: '/', component, }) const customRejection = createRejection({ type: 'CustomRejection', component: { template: '
This is a custom rejection
' }, }) const router = createRouter([route], { initialUrl: '/', rejections: [customRejection], }) router.onBeforeRouteUpdate((_to, { reject, push }) => { expectTypeOf(reject).parameters.toEqualTypeOf<[BuiltInRejectionType | 'CustomRejection']>() // ok push('root') // @ts-expect-error does not know about routes outside of router push('fakeRoute') }) }) }) describe('route', () => { test('route is never if there are no named routes', () => { const route = createRoute({ component, }) expectTypeOf(route.name).toEqualTypeOf<''>() const router = createRouter([route]) expectTypeOf(router.route).toEqualTypeOf() }) test('route union does not include routes without a name', () => { // does not include routes without a name const routeA = createRoute({ component, }) // does not include routes with an empty name const routeB = createRoute({ name: '', component, }) const routeC = createRoute({ name: 'routeC', component, }) const routeD = createRoute({ name: 'routeD', component, }) const router = createRouter([routeA, routeB, routeC, routeD]) expectTypeOf(router.route).toEqualTypeOf>() }) }) describe('route.matched.meta', () => { test('is always defined', () => { const routeA = createRoute({ name: 'routeA', }) const router = createRouter([routeA]) expectTypeOf(router.route.matched.meta).toEqualTypeOf>() }) test('union type is preserved', () => { const routeA = createRoute({ name: 'routeA', }) const routeB = createRoute({ name: 'routeB', meta: { public: true }, }) const router = createRouter([routeA, routeB]) expectTypeOf(router.route.matched.meta).toEqualTypeOf | Readonly<{ public: true }>>() }) test('union type can be narrowed', () => { const routeA = createRoute({ name: 'routeA', }) const routeB = createRoute({ name: 'routeB', meta: { public: true }, }) const router = createRouter([routeA, routeB]) if (router.route.matched.name === 'routeA') { expectTypeOf(router.route.matched.meta).toEqualTypeOf>() } if (router.route.matched.name === 'routeB') { expectTypeOf(router.route.matched.meta).toEqualTypeOf>() } if ('public' in router.route.matched.meta) { expectTypeOf(router.route.matched.meta.public).toEqualTypeOf() } }) }) ================================================ FILE: src/services/createRouter.spec.ts ================================================ import { flushPromises } from '@vue/test-utils' import { Location } from '@/services/history' import { describe, expect, test, vi } from 'vitest' import { computed, toRefs } from 'vue' import { DuplicateNamesError } from '@/errors/duplicateNamesError' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import * as createRouterHistoryUtilities from '@/services/createRouterHistory' import { component, routes } from '@/utilities/testHelpers' import { createExternalRoute } from '@/services/createExternalRoute' import { RouteNotFoundError } from '@/errors/routeNotFoundError' import { InvalidRouteParamValueError } from '@/errors/invalidRouteParamValueError' import { createRejection } from './createRejection' test('initial route is set', async () => { const foo = createRoute({ name: 'root', component, path: '/', }) const { route, start } = createRouter([foo], { initialUrl: '/', }) await start() expect(route.matched.name).toBe('root') }) test('initial state is set', async () => { const location: Location = { key: 'foo', pathname: '/', search: '', hash: '', state: { zoo: '123' }, } const actual = createRouterHistoryUtilities.createRouterHistory({ listener: () => {} }) vi.spyOn(createRouterHistoryUtilities, 'createRouterHistory').mockImplementation(() => ({ ...actual, location, })) const foo = createRoute({ name: 'root', component, path: '/', state: { zoo: Number }, }) const { route, start } = createRouter([foo], { initialUrl: '/', }) await start() expect(route.state).toMatchObject({ zoo: 123 }) }) test('updates the route when navigating', async () => { const theRoute = createRoute({ name: 'first', component, path: '/first', }) const routes = [ theRoute, createRoute({ name: 'second', component, path: '/second', }), createRoute({ name: 'third', component, path: '/third/[id]', }), ] const { push, route, start } = createRouter(routes, { initialUrl: '/first', }) await start() await push('first', {}, { state: { foo: 123 } }) expect(route.matched.name).toBe('first') await push('/second') expect(route.matched.name).toBe('second') }) test('route update updates the current route', async () => { const route = createRoute( { name: 'root', component, path: '/[param]', }) const router = createRouter([route], { initialUrl: '/one', }) await router.start() await router.route.update('param', 'two') expect(router.route.params.param).toBe('two') await router.route.update({ param: 'three', }) expect(router.route.params.param).toBe('three') }) test.fails('route is readonly except for individual params', async () => { const routes = [ createRoute({ name: 'root', component, path: '/', }), ] const { route, start } = createRouter(routes, { initialUrl: '/', }) await start() // @ts-expect-error value is immutable route.name = 'child' expect(route.name).toBe('root') // @ts-expect-error value is immutable route.matched = 'match' expect(route.matched).toMatchObject(routes[0].matched) // @ts-expect-error value is immutable route.matches = 'matches' expect(route.matches).toMatchObject(routes[0].matches) route.params = { foo: 'bar' } expect(route.params).toMatchObject({}) }) test('individual params are writable', async () => { const routes = [ createRoute({ name: 'root', component, path: '/[param]', }), ] const { route, start } = createRouter(routes, { initialUrl: '/one', }) await start() route.params.param = 'goodbye' await flushPromises() expect(route.params.param).toBe('goodbye') const { param } = toRefs(route.params) param.value = 'again' await flushPromises() expect(route.params.param).toBe('again') // @ts-expect-error value is immutable route.params.nothing = 'nothing' await flushPromises() // @ts-expect-error value is immutable expect(route.params.nothing).toBeUndefined() }) test('individual params are writable when using toRefs', async () => { const routes = [ createRoute({ name: 'root', component, path: '/[param]', }), ] const { route, start } = createRouter(routes, { initialUrl: '/one', }) await start() const { param } = toRefs(route.params) param.value = 'two' await flushPromises() expect(route.params.param).toBe('two') }) test('setting an unknown param does not add its value to the route', async () => { const routes = [ createRoute({ name: 'root', component, path: '/', }), ] const { route, start } = createRouter(routes, { initialUrl: '/', }) await start() // @ts-expect-error value is immutable route.params.nothing = 'nothing' await flushPromises() // @ts-expect-error value is immutable expect(route.params.nothing).toBeUndefined() }) test('params are writable', async () => { const routes = [ createRoute({ name: 'root', component, path: '/[paramA]/[paramB]/[?paramC]', }), ] const { route, start } = createRouter(routes, { initialUrl: '/one/two/three', }) await start() expect(route.params).toMatchObject({ paramA: 'one', paramB: 'two', paramC: 'three', }) route.params = { paramA: 'four', paramB: 'five', } await flushPromises() expect(route.params).toMatchObject({ paramA: 'four', paramB: 'five', }) const { params } = toRefs(route) params.value = { paramA: 'six', paramB: 'seven', } await flushPromises() expect(route.params).toMatchObject({ paramA: 'six', paramB: 'seven', }) }) test('params can be destructured', async () => { const root = createRoute({ name: 'root', component, path: '/[paramA]/[paramB]', }) const { route, start, push } = createRouter([root], { initialUrl: '/one/two', }) await start() const { paramA, paramB } = toRefs(route.params) expect(paramA.value).toBe('one') expect(paramB.value).toBe('two') await push('root', { paramA: 'three', paramB: 'four' }) expect(paramA.value).toBe('three') expect(paramB.value).toBe('four') paramA.value = 'five' await flushPromises() expect(route.params.paramA).toBe('five') }) test('query is writable', async () => { const root = createRoute({ name: 'root', component, path: '/', }) const { route, start } = createRouter([root], { initialUrl: '/?foo=bar&fiz=buz', }) await start() route.query = 'foo=bar&foo=baz' await flushPromises() expect(route.query.toString()).toBe('foo=bar&foo=baz') const { query } = toRefs(route) // @ts-expect-error vue's `reactive` utility loses the type information for computed setters but we do expect this to work query.value = 'foo2=bar2&foo2=baz2' await flushPromises() expect(route.query.toString()).toBe('foo2=bar2&foo2=baz2') }) test('query.set updates the route', async () => { const root = createRoute({ name: 'root', component, path: '/', }) const { route, start } = createRouter([root], { initialUrl: '/', }) await start() route.query.set('foo', 'bar') await flushPromises() expect(route.query.toString()).toBe('foo=bar') route.query.set('fuz', 'buz') await flushPromises() expect(route.query.toString()).toBe('foo=bar&fuz=buz') }) test('query.set does not duplicate existing params when updating one param', async () => { const root = createRoute({ name: 'root', component, path: '/', query: 'param=[param]', }) const { route, start } = createRouter([root], { initialUrl: '/?param=value&foo=1', }) await start() expect(route.query.toString()).toBe('param=value&foo=1') route.query.set('foo', '2') await flushPromises() expect(route.query.toString()).toBe('param=value&foo=2') }) test('query.set can change the value of a param that is already set', async () => { const root = createRoute({ name: 'root', component, query: 'param=[param]', path: '/', }) const { route, start } = createRouter([root], { initialUrl: '/?param=foo¬AParam=1', }) await start() route.query.set('param', 'bar') await flushPromises() expect(route.query.toString()).toBe('param=bar¬AParam=1') }) test('query.append updates the route', async () => { const root = createRoute({ name: 'root', component, path: '/', }) const { route, start } = createRouter([root], { initialUrl: '/', }) await start() route.query.append('foo', 'bar') await flushPromises() expect(route.query.toString()).toBe('foo=bar') route.query.append('fuz', 'buz') await flushPromises() expect(route.query.toString()).toBe('foo=bar&fuz=buz') }) test('query.delete updates the route', async () => { const root = createRoute({ name: 'root', component, path: '/', }) const { route, start } = createRouter([root], { initialUrl: '/?foo=bar&fiz=buz', }) await start() route.query.delete('foo') await flushPromises() expect(route.query.toString()).toBe('fiz=buz') }) test('query.values is reactive', async () => { const root = createRoute({ name: 'root', component, path: '/', }) const { route, start } = createRouter([root], { initialUrl: '/?foo=foo1&bar=bar1', }) await start() const values = computed(() => Array.from(route.query.values())) expect(values.value).toMatchObject(['foo1', 'bar1']) route.query.append('foo', 'foo2') await flushPromises() expect(values.value).toMatchObject(['foo1', 'bar1', 'foo2']) }) test('given an array of Routes with duplicate names, throws DuplicateNamesError', () => { const aRoutes = [ createRoute({ name: 'foo', component }), createRoute({ name: 'bar', component }), ] const bRoutes = [ createRoute({ name: 'zoo', component }), createRoute({ name: 'bar', component }), ] const action: () => void = () => createRouter([aRoutes, bRoutes], { initialUrl: '/', }) expect(action).toThrow(DuplicateNamesError) }) test('given an array of Routes with missing context, can still match missing routes', async () => { const missingRoute = createRoute({ name: 'missing', path: '/missing', component }) const router = createRouter([ createRoute({ name: 'foo', path: '/foo', component, context: [missingRoute] }), createRoute({ name: 'bar', path: '/bar', component }), ], { initialUrl: '/missing', }) await router.start() expect(router.route.name).toBe('missing') }) test('given an array of Routes with missing context with duplicate route names, throws DuplicateNamesError', async () => { const missingRoute = createRoute({ name: 'foo', component }) const action: () => void = () => createRouter([ createRoute({ name: 'foo', component, context: [missingRoute] }), ], { initialUrl: '/missing', }) expect(action).toThrow(DuplicateNamesError) }) test('initial route is not set until the router is started', async () => { const route = createRoute({ name: 'root', path: '/', component, }) const router = createRouter([route], { initialUrl: '/', }) expect(router.route.name).toBe('NotFound') await router.start() expect(router.route.name).toBe('root') }) describe('router.resolve', () => { test('when given a name that matches a route return that route', () => { const router = createRouter(routes, { initialUrl: '/' }) const route = router.resolve('parentB') expect(route).toBeDefined() expect(route.name).toBe('parentB') }) test('when given a name that does not match a route throws RouteNotFoundError', () => { const router = createRouter(routes, { initialUrl: '/' }) const action: () => void = () => router.resolve('parentD' as any) expect(action).toThrow(RouteNotFoundError) }) test('given a route name with params, interpolates param values', () => { const router = createRouter(routes, { initialUrl: '/' }) const route = router.resolve('parentA', { paramA: 'bar' }) expect(route).toMatchObject({ name: 'parentA', params: { paramA: 'bar', }, href: '/parentA/bar', }) }) test('given a route name with query, interpolates param values', () => { const router = createRouter(routes, { initialUrl: '/' }) const route = router.resolve('parentA', { paramA: 'bar' }, { query: { foo: 'foo' } }) expect(route).toMatchObject({ name: 'parentA', params: { paramA: 'bar', }, href: '/parentA/bar?foo=foo', }) }) test('given a route name with params cannot be matched, throws InvalidRouteParamValueError', () => { const router = createRouter(routes, { initialUrl: '/' }) const action: () => void = () => router.resolve('parentA', { missing: 'foo' } as any) expect(action).toThrow(InvalidRouteParamValueError) }) test('given a param with a dash or underscore resolves the correct url', () => { const routes = [ createRoute({ name: 'kebab', path: '/[test-param]', component, }), createRoute({ name: 'snake', path: '/[test_param]', component, }), ] const router = createRouter(routes, { initialUrl: '/' }) const kebab = router.resolve('kebab', { 'test-param': 'foo' }) expect(kebab).toMatchObject({ name: 'kebab', params: { 'test-param': 'foo', }, href: '/foo', }) const snake = router.resolve('snake', { test_param: 'foo' }) expect(snake).toMatchObject({ name: 'snake', params: { test_param: 'foo', }, href: '/foo', }) }) test('when given an external route returns a fully qualified url', () => { const routes = [createExternalRoute({ host: 'https://kitbag.dev', name: 'external', path: '/', })] const router = createRouter(routes, { initialUrl: '/' }) const route = router.resolve('external') expect(route).toMatchObject({ name: 'external', href: 'https://kitbag.dev/', }) }) test('when given an external route with params in host, interpolates param values', () => { const routes = [createExternalRoute({ host: 'https://[subdomain].kitbag.dev', name: 'external', path: '/', })] const router = createRouter(routes, { initialUrl: '/' }) const route = router.resolve('external', { subdomain: 'router' }) expect(route).toMatchObject({ name: 'external', href: 'https://router.kitbag.dev/', }) }) test('given a route with hash, interpolates hash value', () => { const router = createRouter(routes, { initialUrl: '/' }) const route = router.resolve('parentA', { paramA: 'bar' }, { hash: 'foo' }) expect(route).toMatchObject({ name: 'parentA', params: { paramA: 'bar', }, hash: '#foo', href: '/parentA/bar#foo', }) }) }) describe('router.push', () => { test('given a resolved route, pushes the route', async () => { const router = createRouter(routes, { initialUrl: '/' }) const route = router.resolve('parentA', { paramA: 'bar' }) await router.push(route) expect(router.route.href).toBe('/parentA/bar') }) test('given a route name, pushes the route', async () => { const router = createRouter(routes, { initialUrl: '/' }) await router.push('parentA', { paramA: 'bar' }) expect(router.route.href).toBe('/parentA/bar') }) test('given an internal Url, pushes the route', async () => { const router = createRouter(routes, { initialUrl: '/' }) await router.push('/parentA/bar') expect(router.route).toMatchObject({ name: 'parentA', params: { paramA: 'bar', }, }) }) test('given a resolved route with state inside, pushes state', async () => { const routeWithState = createRoute({ name: 'route-with-state', component, path: '/route-with-state', state: { zoo: Number }, }) const router = createRouter([routeWithState], { initialUrl: '/' }) const route = router.resolve('route-with-state', { paramA: 'bar' }, { state: { zoo: 123 } }) await router.push(route) expect(router.route.state).toMatchObject({ zoo: 123 }) }) test('given a resolved route with state in options, pushes state', async () => { const routeWithState = createRoute({ name: 'route-with-state', component, path: '/route-with-state', state: { zoo: Number }, }) const router = createRouter([routeWithState], { initialUrl: '/' }) const route = router.resolve('route-with-state', { paramA: 'bar' }) await router.push(route, { state: { zoo: 123 } }) expect(router.route.state).toMatchObject({ zoo: 123 }) }) test('given a route name with state in options, pushes state', async () => { const routeWithState = createRoute({ name: 'route-with-state', component, path: '/route-with-state', state: { zoo: Number }, }) const router = createRouter([routeWithState], { initialUrl: '/' }) await router.push('route-with-state', { paramA: 'bar' }, { state: { zoo: 123 } }) expect(router.route.state).toMatchObject({ zoo: 123 }) }) test('given an internal Url with state in options, pushes state', async () => { const routeWithState = createRoute({ name: 'route-with-state', component, path: '/route-with-state', state: { zoo: Number }, }) const router = createRouter([routeWithState], { initialUrl: '/' }) await router.push('/route-with-state', { state: { zoo: 123 } }) expect(router.route.state).toMatchObject({ zoo: 123 }) }) }) describe('router.onError', () => { test.each([ { hook: 'onBeforeRouteEnter' }, { hook: 'onAfterRouteEnter' }, ] as const)('given an error thrown in $hook, calls the onError callback with the correct context', async ({ hook }) => { const error = new Error('Test error') const onError = vi.fn() const route = createRoute({ name: 'route-with-error', component, path: '/', }) const router = createRouter([route], { initialUrl: '/' }) router[hook](() => { throw error }) router.onError(onError) await router.push('route-with-error') expect(onError).toHaveBeenCalledWith(error, expect.objectContaining({ source: 'hook', })) }) test.each([ { hook: 'onBeforeRouteUpdate' }, { hook: 'onAfterRouteUpdate' }, ] as const)('given an error thrown in $hook, calls the onError callback with the correct context', async ({ hook }) => { const error = new Error('Test error') const onError = vi.fn() const route = createRoute({ name: 'route-with-error', component, path: '/[param]', }) const router = createRouter([route], { initialUrl: '/' }) await router.start() router[hook](() => { throw error }) router.onError(onError) // Navigate to the route first await router.push('route-with-error', { param: 'bar' }) // Then navigate to the same route again to trigger update await router.push('route-with-error', { param: 'foo' }) expect(onError).toHaveBeenCalledWith(error, expect.objectContaining({ source: 'hook', })) }) test.each([ { hook: 'onBeforeRouteLeave' }, { hook: 'onAfterRouteLeave' }, ] as const)('given an error thrown in $hook, calls the onError callback with the correct context', async ({ hook }) => { const error = new Error('Test error') const onError = vi.fn() const route1 = createRoute({ name: 'route-1', component, path: '/route-1', }) const route2 = createRoute({ name: 'route-2', component, path: '/route-2', }) const router = createRouter([route1, route2], { initialUrl: '/' }) await router.start() router[hook](() => { throw error }) router.onError(onError) // Navigate to route-1 first await router.push('route-1') // Then navigate to route-2 to trigger leave await router.push('route-2') expect(onError).toHaveBeenCalledWith(error, expect.objectContaining({ source: 'hook', })) }) test('given an error thrown in a props callback, calls the onError callback with the correct context', async () => { const error = new Error('Test error') const onError = vi.fn() const routeWithError = createRoute({ name: 'route-with-error', component, path: '/route-with-error', }, () => { throw error }) const router = createRouter([routeWithError], { initialUrl: '/' }) await router.start() router.onError(onError) await router.push('route-with-error') await flushPromises() expect(onError).toHaveBeenCalledWith(error, expect.objectContaining({ source: 'props', })) }) }) describe('router.onRejection', () => { test('given router itself triggers a rejection, calls the onRejection callback with the correct context', async () => { const onRejection = vi.fn() const rejection = createRejection({ type: 'CustomRejection', component: { template: '
This is a custom rejection
' }, }) const route = createRoute({ name: 'route', component, path: '/', }) const router = createRouter([route], { initialUrl: '/', rejections: [rejection] }) router.onRejection(onRejection) await router.start() router.reject('CustomRejection') expect(onRejection).toHaveBeenCalledWith('CustomRejection', { to: null, from: null, }) }) test('given route hooks that trigger a rejection, calls the onRejection callback with the correct context', async () => { const onRejection = vi.fn() const route = createRoute({ name: 'route-with-rejection', component, path: '/', }) route.onBeforeRouteEnter((_to, { reject }) => { reject('NotFound') }) const router = createRouter([route], { initialUrl: '/' }) router.onRejection(onRejection) await router.start() expect(onRejection).toHaveBeenCalledWith('NotFound', { to: expect.objectContaining({ name: 'route-with-rejection', }), from: null, }) }) }) describe('options.removeTrailingSlashes', () => { const foo = createRoute({ name: 'foo', component, path: '/foo', }) const fooWithTrailingSlash = createRoute({ name: 'fooWithTrailingSlash', component, path: '/foo/', }) const bar = createRoute({ name: 'bar', component, path: '/bar', }) test('when true, removes trailing slashes from the path', async () => { const router = createRouter([foo, fooWithTrailingSlash, bar], { initialUrl: '/foo/', removeTrailingSlashes: true, }) await router.start() // trims the trailing slash from the initial url expect(router.route.href).toBe('/foo') await router.push('/bar/') // trims the trailing slash from the path expect(router.route.href).toBe('/bar') await router.push('fooWithTrailingSlash') // trims the trailing slash from the path expect(router.route.href).toBe('/foo') }) test('when false, does not remove trailing slashes from the path', async () => { const router = createRouter([foo, fooWithTrailingSlash, bar], { initialUrl: '/foo/', removeTrailingSlashes: false, }) await router.start() expect(router.route.href).toBe('/foo/') await router.push('fooWithTrailingSlash') expect(router.route.href).toBe('/foo/') await router.push('/bar/') // rejects so has an empty path expect(router.route.href).toBe('/') }) }) ================================================ FILE: src/services/createRouter.ts ================================================ import { createPath } from '@/services/history' import { App, ref } from 'vue' import { createCurrentRoute } from '@/services/createCurrentRoute' import { createIsExternal } from '@/services/createIsExternal' import { parseUrl, updateUrl } from '@/services/urlParser' import { createPropStore } from '@/services/createPropStore' import { createRouterHistory } from '@/services/createRouterHistory' import { createRouterHooks, getRouterHooksKey } from '@/services/createRouterHooks' import { getInitialUrl } from '@/services/getInitialUrl' import { setStateValues } from '@/services/state' import { Routes } from '@/types/route' import { Router, RouterOptions } from '@/types/router' import { RouterPush, RouterPushOptions } from '@/types/routerPush' import { RouterReplace, RouterReplaceOptions } from '@/types/routerReplace' import { RoutesName } from '@/types/routesMap' import { UrlString, isUrlString } from '@/types/urlString' import { createUniqueIdSequence, isFirstUniqueSequenceId } from '@/services/createUniqueIdSequence' import { createVisibilityObserver } from './createVisibilityObserver' import { visibilityObserverKey } from '@/compositions/useVisibilityObserver' import { RouterResolve, RouterResolveOptions } from '@/types/routerResolve' import { RouteNotFoundError } from '@/errors/routeNotFoundError' import { createResolvedRoute } from '@/services/createResolvedRoute' import { ResolvedRoute } from '@/types/resolved' import { RouterReject } from '@/types/routerReject' import { EmptyRouterPlugin, RouterPlugin } from '@/types/routerPlugin' import { getRoutesForRouter } from './getRoutesForRouter' import { getGlobalHooksForRouter } from './getGlobalHooksForRouter' import { createComponentsStore } from './createComponentsStore' import { initZod, zodParamsDetected } from './zod' import { getComponentsStoreKey } from '@/compositions/useComponentsStore' import { getPropStoreInjectionKey } from '@/compositions/usePropStore' import { getRouterRejectionInjectionKey } from '@/compositions/useRejection' import { routerInjectionKey } from '@/keys' import { createRouterView } from '@/components/routerView' import { createRouterLink } from '@/components/routerLink' import { ContextPushError } from '@/errors/contextPushError' import { ContextRejectionError } from '@/errors/contextRejectionError' import { setupRouterDevtools } from '@/devtools/createRouterDevtools' import { getMatchForUrl } from './getMatchesForUrl' import { pathHasTrailingSlash, removeTrailingSlashesFromPath } from '@/utilities/trailingSlashes' import { setDocumentTitle } from '@/utilities/setDocumentTitle' import { createCurrentRejection } from '@/services/createCurrentRejection' type RouterUpdateOptions = { replace?: boolean, state?: any, } /** * Creates a router instance for a Vue application, equipped with methods for route handling, lifecycle hooks, and state management. * * @param routes - {@link Routes} An array of route definitions specifying the configuration of routes in the application. * Use createRoute method to create the route definitions. * @param options - {@link RouterOptions} for the router, including history mode and initial URL settings. * @returns Router instance * * @example * ```ts * import { createRoute, createRouter } from '@kitbag/router' * * const Home = { template: '
Home
' } * const About = { template: '
About
' } * * export const routes = [ * createRoute({ name: 'home', path: '/', component: Home }), * createRoute({ name: 'path', path: '/about', component: About }), * ] as const * * const router = createRouter(routes) * ``` */ export function createRouter< const TRoutes extends Routes, const TOptions extends RouterOptions = {}, const TPlugin extends RouterPlugin = EmptyRouterPlugin >(routes: TRoutes, options?: TOptions, plugins?: TPlugin[]): Router export function createRouter< const TRoutes extends Routes, const TOptions extends RouterOptions = {}, const TPlugin extends RouterPlugin = EmptyRouterPlugin >(routes: TRoutes[], options?: TOptions, plugins?: TPlugin[]): Router export function createRouter< const TRoutes extends Routes, const TOptions extends RouterOptions = {}, const TPlugin extends RouterPlugin = EmptyRouterPlugin >(routesOrArrayOfRoutes: TRoutes | TRoutes[], options?: TOptions, plugins: TPlugin[] = []): Router { const isGlobalRouter = options?.isGlobalRouter ?? true const routerKey = isGlobalRouter ? routerInjectionKey : Symbol() const shouldRemoveTrailingSlashes = options?.removeTrailingSlashes ?? true const { routes, getRouteByName, getRejectionByType } = getRoutesForRouter(routesOrArrayOfRoutes, plugins, options) const notFoundRejection = getRejectionByType('NotFound') const notFoundRoute = createResolvedRoute(notFoundRejection.route) const hooks = createRouterHooks() hooks.addGlobalRouteHooks(getGlobalHooksForRouter(plugins)) const getNavigationId = createUniqueIdSequence() const propStore = createPropStore() const componentsStore = createComponentsStore(routerKey) const visibilityObserver = createVisibilityObserver() const history = createRouterHistory({ mode: options?.historyMode, listener: ({ location }) => { const url = createPath(location) set(url, { state: location.state, replace: true }) }, }) function find(url: string, resolveOptions: RouterResolveOptions = {}): ResolvedRoute | undefined { const urlIsRelative = !isExternal(url) const filteredRoutes = routes.filter((route) => route.isRelative === urlIsRelative) const parseOptions = { removeTrailingSlashes: shouldRemoveTrailingSlashes } return getMatchForUrl(filteredRoutes, url, { ...resolveOptions, ...parseOptions }) } async function set(url: string, options: RouterUpdateOptions = {}): Promise { if (pathHasTrailingSlash(url) && shouldRemoveTrailingSlashes) { const cleanedUrl = removeTrailingSlashesFromPath(url) if (isUrlString(cleanedUrl)) { return replace(cleanedUrl, options) } } const navigationId = getNavigationId() history.stopListening() const to = find(url, options) ?? notFoundRoute const from = getFromRouteForHooks(navigationId) const beforeResponse = await hooks.runBeforeRouteHooks({ to, from }) switch (beforeResponse.status) { // On abort do nothing case 'ABORT': return // On push update the history, and push new route, and return case 'PUSH': history.update(url, options) await push(...beforeResponse.to) return // On reject update the history, the route, and set the rejection type case 'REJECT': history.update(url, options) setRejection(beforeResponse.type, to, from) break // On success update history, set the route, and clear the rejection case 'SUCCESS': history.update(url, options) clearRejection() break default: throw new Error(`Switch is not exhaustive for before hook response status: ${JSON.stringify(beforeResponse satisfies never)}`) } if (!isExternal(url)) { setPropsAndUpdateRoute(navigationId, to, from) } const afterResponse = await hooks.runAfterRouteHooks({ to, from }) switch (afterResponse.status) { case 'PUSH': await push(...afterResponse.to) break case 'REJECT': setRejection(afterResponse.type, to, from) break case 'SUCCESS': break default: const exhaustive: never = afterResponse throw new Error(`Switch is not exhaustive for after hook response status: ${JSON.stringify(exhaustive)}`) } setDocumentTitle(currentRejectionRoute.value ?? to) history.startListening() } function setPropsAndUpdateRoute(navigationId: string, to: ResolvedRoute, from: ResolvedRoute | null): void { const currentNavigationId = navigationId propStore.setProps(to) .then((response) => { if (currentNavigationId !== navigationId) { return } switch (response.status) { case 'SUCCESS': break case 'PUSH': push(...response.to) break case 'REJECT': setRejection(response.type, to, from) break default: const exhaustive: never = response throw new Error(`Switch is not exhaustive for prop store response status: ${JSON.stringify(exhaustive)}`) } }) .catch((error: unknown) => { try { hooks.runErrorHooks(error, { to, from, source: 'props' }) } catch (error) { if (error instanceof ContextPushError) { push(...error.response.to) return } if (error instanceof ContextRejectionError) { setRejection(error.response.type, to, from) return } throw error } }) updateRoute(to) } const resolve: RouterResolve = ( source: RoutesName, params: Record = {}, options: RouterResolveOptions = {}, ) => { const match = getRouteByName(source) if (!match) { throw new RouteNotFoundError(source) } return createResolvedRoute(match, params, options) } const push: RouterPush = ( source: UrlString | RoutesName | ResolvedRoute, paramsOrOptions?: Record | RouterPushOptions, maybeOptions?: RouterPushOptions, ) => { if (isUrlString(source)) { const options: RouterPushOptions = { ...paramsOrOptions } const url = updateUrl(source, { query: options.query, hash: options.hash, }) return set(url, options) } if (typeof source === 'string') { const { replace, ...options }: RouterPushOptions = { ...maybeOptions } const params: any = { ...paramsOrOptions } const resolved = resolve(source, params, options) const state = setStateValues({ ...resolved.matched.state }, { ...resolved.state, ...options.state }) return set(resolved.href, { replace, state }) } const { replace, ...options }: RouterPushOptions = { ...paramsOrOptions } const state = setStateValues({ ...source.matched.state }, { ...source.state, ...options.state }) const url = updateUrl(source.href, { query: options.query, hash: options.hash, }) return set(url, { replace, state }) } const replace: RouterReplace = ( source: UrlString | RoutesName | ResolvedRoute, paramsOrOptions?: Record | RouterReplaceOptions, maybeOptions?: RouterReplaceOptions, ) => { if (isUrlString(source)) { const options: RouterPushOptions = { ...paramsOrOptions, replace: true } return push(source, options) } if (typeof source === 'string') { const options: RouterPushOptions = { ...maybeOptions, replace: true } const params: any = { ...paramsOrOptions } return push(source, params, options) } const options: RouterPushOptions = { ...paramsOrOptions, replace: true } return push(source, options) } function setRejection(type: string, to: ResolvedRoute | null = null, from: ResolvedRoute | null = null): void { const rejection = getRejectionByType(type) if (!rejection) { return } hooks.runRejectionHooks(rejection, { to, from }) updateRejection(rejection) } const reject: RouterReject = (type) => { setRejection(type) setDocumentTitle(currentRejectionRoute.value) } const { currentRejection, currentRejectionRoute, updateRejection, clearRejection } = createCurrentRejection() const { currentRoute, routerRoute, updateRoute } = createCurrentRoute(routerKey, notFoundRoute, push) const initialUrl = getInitialUrl(options?.initialUrl) const initialState = history.location.state const { host } = parseUrl(initialUrl) const isExternal = createIsExternal(host) let starting = false const started = ref(false) // eslint is just incorrect here // eslint-disable-next-line @typescript-eslint/no-invalid-void-type const { promise: initialize, resolve: initialized } = Promise.withResolvers() async function start(): Promise { if (starting) { return initialize } starting = true const shouldInitZod = zodParamsDetected(routes) if (shouldInitZod) { await initZod() } await set(initialUrl, { replace: true, state: initialState }) history.startListening() initialized() started.value = true } function stop(): void { history.stopListening() } function getFromRouteForHooks(navigationId: string): ResolvedRoute | null { return isFirstUniqueSequenceId(navigationId) ? null : { ...currentRoute } } function install(app: App): void { hooks.setVueApp(app) propStore.setVueApp(app) const routerView = createRouterView(routerKey) const routerLink = createRouterLink(routerKey) app.component('RouterView', routerView) app.component('RouterLink', routerLink) app.provide(getRouterRejectionInjectionKey(routerKey), currentRejection) app.provide(getRouterHooksKey(routerKey), hooks) app.provide(getPropStoreInjectionKey(routerKey), propStore) app.provide(getComponentsStoreKey(routerKey), componentsStore) app.provide(visibilityObserverKey, visibilityObserver) app.provide(routerKey, router) // Setup DevTools integration setupRouterDevtools({ router, app, routes }) start() } const router: Router = { route: routerRoute, resolve, find, push, replace, reject, refresh: history.refresh, forward: history.forward, back: history.back, go: history.go, install, isExternal, onBeforeRouteEnter: hooks.onBeforeRouteEnter, onBeforeRouteUpdate: hooks.onBeforeRouteUpdate, onBeforeRouteLeave: hooks.onBeforeRouteLeave, onAfterRouteEnter: hooks.onAfterRouteEnter, onAfterRouteUpdate: hooks.onAfterRouteUpdate, onAfterRouteLeave: hooks.onAfterRouteLeave, onError: hooks.onError, onRejection: hooks.onRejection, prefetch: options?.prefetch, start, started, stop, key: routerKey, hasDevtools: false, } return router } ================================================ FILE: src/services/createRouterAssets.ts ================================================ import { Router, RouterRoutes, RouterRejections } from '@/types/router' import { InjectionKey } from 'vue' import { createComponentHooks } from './createComponentHooks' import { createRouterView } from '@/components/routerView' import { createRouterLink } from '@/components/routerLink' import { createUseRoute } from '@/compositions/useRoute' import { createUseRouter } from '@/compositions/useRouter' import { createUseQueryValue } from '@/compositions/useQueryValue' import { createUseLink } from '@/compositions/useLink' import { createIsRoute } from '@/guards/routes' import { AddBeforeLeaveHook, AddBeforeUpdateHook, AddAfterLeaveHook, AddAfterUpdateHook } from '@/types/hooks' import { createUseRejection } from '@/compositions/useRejection' export type RouterAssets = { /** * Registers a hook that is called before a route is left. Must be called from setup. * This is useful for performing actions or cleanups before navigating away from a route component. * * @param BeforeRouteHook - The hook callback function * @returns {RouteHookRemove} A function that removes the added hook. * @group Hooks */ onBeforeRouteLeave: AddBeforeLeaveHook, RouterRejections>, /** * Registers a hook that is called before a route is updated. Must be called from setup. * This is particularly useful for handling changes in route parameters or query while staying within the same component. * * @param BeforeRouteHook - The hook callback function * @returns {RouteHookRemove} A function that removes the added hook. * @group Hooks */ onBeforeRouteUpdate: AddBeforeUpdateHook, RouterRejections>, /** * Registers a hook that is called after a route has been left. Must be called during setup. * This can be used for cleanup actions after the component is no longer active, ensuring proper resource management. * * @param AfterRouteHook - The hook callback function * @returns {RouteHookRemove} A function that removes the added hook. * @group Hooks */ onAfterRouteLeave: AddAfterLeaveHook, RouterRejections>, /** * Registers a hook that is called after a route has been updated. Must be called during setup. * This is ideal for responding to updates within the same route, such as parameter changes, without full component reloads. * * @param AfterRouteHook - The hook callback function * @returns {RouteHookRemove} A function that removes the added hook. * @group Hooks */ onAfterRouteUpdate: AddAfterUpdateHook, RouterRejections>, /** * A guard to verify if a route or unknown value matches a given route name. * * @param routeName - The name of the route to check against the current route. * @returns True if the current route matches the given route name, false otherwise. * @group Guards */ isRoute: ReturnType>, /** * A component to render the current route's component. * * @param props - The props to pass to the router view component. * @returns The router view component. * @group Components */ RouterView: ReturnType>, /** * A component to render a link to a route or any url. * * @param props - The props to pass to the router link component. * @returns The router link component. * @group Components */ RouterLink: ReturnType>, /** * A composition to access the current route or verify a specific route name within a Vue component. * This function provides two overloads: * 1. When called without arguments, it returns the current route from the router without types. * 2. When called with a route name, it checks if the current active route includes the specified route name. * * The function also sets up a reactive watcher on the route object from the router to continually check the validity of the route name * if provided, throwing an error if the validation fails at any point during the component's lifecycle. * * @template TRouteName - A string type that should match route name of `RouterRouteName`, ensuring the route name exists. * @param routeName - Optional. The name of the route to validate against the current active routes. * @returns The current router route. If a route name is provided, it validates the route name first. * @throws {UseRouteInvalidError} Throws an error if the provided route name is not valid or does not match the current route. * @group Compositions */ useRoute: ReturnType>, /** * A composition to access the installed router instance within a Vue component. * * @returns The installed router instance. * @throws {RouterNotInstalledError} Throws an error if the router has not been installed, * ensuring the component does not operate without routing functionality. * @group Compositions */ useRouter: ReturnType>, /** * A composition to access a specific query value from the current route. * * @returns The query value from the router. * @group Compositions */ useQueryValue: ReturnType>, /** * A composition to export much of the functionality that drives RouterLink component. * Also exports some useful context about routes relationship to current URL and convenience methods * for navigating. * * @param source - The name of the route or a valid URL. * @param params - If providing route name, this argument will expect corresponding params. * @param options - {@link RouterResolveOptions} Same options as router resolve. * @returns {UseLink} Reactive context values for as well as navigation methods. * @group Compositions */ useLink: ReturnType>, /** * A composition to access the rejection from the router. * * @returns The rejection from the router. * @group Compositions */ useRejection: ReturnType>, } export function createRouterAssets(router: TRouter): RouterAssets export function createRouterAssets(routerKey: InjectionKey): RouterAssets export function createRouterAssets(routerOrRouterKey: TRouter | InjectionKey): RouterAssets { const routerKey: InjectionKey = typeof routerOrRouterKey === 'object' ? routerOrRouterKey.key : routerOrRouterKey const { onBeforeRouteLeave, onBeforeRouteUpdate, onAfterRouteLeave, onAfterRouteUpdate, } = createComponentHooks(routerKey) const isRoute = createIsRoute(routerKey) const RouterView = createRouterView(routerKey) const RouterLink = createRouterLink(routerKey) const useRoute = createUseRoute(routerKey) const useRouter = createUseRouter(routerKey) const useQueryValue = createUseQueryValue(routerKey) const useLink = createUseLink(routerKey) const useRejection = createUseRejection(routerKey) return { onBeforeRouteLeave, onBeforeRouteUpdate, onAfterRouteLeave, onAfterRouteUpdate, isRoute, RouterView, RouterLink, useRoute, useRouter, useQueryValue, useLink, useRejection, } } ================================================ FILE: src/services/createRouterCallbackContext.ts ================================================ import { ContextAbortError } from '@/errors/contextAbortError' import { ContextPushError } from '@/errors/contextPushError' import { ContextRejectionError } from '@/errors/contextRejectionError' import { RouterPush, RouterPushOptions } from '@/types/routerPush' import { RouterReject } from '@/types/routerReject' import { RouterReplace } from '@/types/routerReplace' import { isUrlString } from '@/types/urlString' import { Routes } from '@/types/route' import { Rejections } from '@/types/rejection' import { RouteUpdate } from '@/types/routeUpdate' import { ResolvedRoute } from '@/types/resolved' /** * A function that can be called to abort a routing operation. */ export type CallbackContextAbort = () => void type RouterCallbackContext< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = { reject: RouterReject, push: RouterPush, replace: RouterReplace, update: RouteUpdate>, abort: CallbackContextAbort, } export function createRouterCallbackContext< TRoutes extends Routes, TRejections extends Rejections >({ to }: { to: ResolvedRoute }): RouterCallbackContext export function createRouterCallbackContext({ to }: { to: ResolvedRoute }): RouterCallbackContext { const reject: RouterCallbackContext['reject'] = (type) => { throw new ContextRejectionError(type) } const push: RouterCallbackContext['push'] = (...parameters: any[]) => { throw new ContextPushError(parameters) } const replace: RouterCallbackContext['replace'] = (source: any, paramsOrOptions?: any, maybeOptions?: any) => { if (isUrlString(source)) { const options: RouterPushOptions = paramsOrOptions ?? {} throw new ContextPushError([source, { ...options, replace: true }]) } const params = paramsOrOptions const options: RouterPushOptions = maybeOptions ?? {} throw new ContextPushError([source, params, { ...options, replace: true }]) } const update: RouterCallbackContext['update'] = (nameOrParams: PropertyKey | Partial, valueOrOptions?: any, maybeOptions?: RouterPushOptions) => { if (typeof nameOrParams === 'object') { const params = { ...to.params, ...nameOrParams, } return push(to.name, params, valueOrOptions) } const params = { ...to.params, [nameOrParams]: valueOrOptions, } return push(to.name, params, maybeOptions) } const abort: CallbackContextAbort = () => { throw new ContextAbortError() } return { reject, push, replace, update, abort } } ================================================ FILE: src/services/createRouterHistory.browser.spec.ts ================================================ import { beforeEach, expect, test, vi } from 'vitest' import { createRouterHistory } from '@/services/createRouterHistory' import { random } from '@/utilities/testHelpers' function noop(): void {} beforeEach(() => vi.resetAllMocks()) test('when go is called, forwards call to window history', () => { vi.spyOn(window.history, 'go') const delta = random.number({ min: 0, max: 100 }) const history = createRouterHistory({ listener: noop }) history.go(delta) expect(window.history.go).toHaveBeenCalledWith(delta) }) test('when back is called, forwards call to window history', () => { vi.spyOn(window.history, 'go') const history = createRouterHistory({ listener: noop }) history.back() expect(window.history.go).toHaveBeenCalledOnce() }) test('when forward is called, forwards call to window history', () => { vi.spyOn(window.history, 'go') const history = createRouterHistory({ listener: noop }) history.forward() expect(window.history.go).toHaveBeenCalledOnce() }) ================================================ FILE: src/services/createRouterHistory.ts ================================================ import { createBrowserHistory, createHashHistory, createMemoryHistory, createPath, History, Listener } from '@/services/history' import { isBrowser } from '@/utilities/isBrowser' type NavigationPushOptions = { replace?: boolean, state?: unknown, } type NavigationUpdate = (url: string, options?: NavigationPushOptions) => void type NavigationRefresh = () => void type RouterHistory = History & { update: NavigationUpdate, refresh: NavigationRefresh, startListening: () => void, stopListening: () => void, } export type RouterHistoryMode = 'auto' | 'browser' | 'memory' | 'hash' type RouterHistoryOptions = { listener: Listener, mode?: RouterHistoryMode, } export function createRouterHistory({ mode, listener }: RouterHistoryOptions): RouterHistory { const history = createHistory(mode) const update: NavigationUpdate = (url, options) => { if (options?.replace) { history.replace(url, options.state) return } history.push(url, options?.state) } const refresh: NavigationRefresh = () => { const url = createPath(history.location) history.replace(url) } let removeListener: (() => void) | undefined const startListening: () => void = () => { removeListener?.() removeListener = history.listen(listener) } const stopListening: () => void = () => { removeListener?.() } return { ...history, update, refresh, startListening, stopListening, } } function createHistory(mode: RouterHistoryMode = 'auto'): History { switch (mode) { case 'auto': return isBrowser() ? createBrowserHistory() : createMemoryHistory() case 'browser': return createBrowserHistory() case 'memory': return createMemoryHistory() case 'hash': return createHashHistory() default: const exhaustive: never = mode throw new Error(`Switch is not exhaustive for mode: ${exhaustive}`) } } ================================================ FILE: src/services/createRouterHooks.ts ================================================ import { AddGlobalHooks, AddComponentHook, AfterHookRunner, BeforeHookRunner, AddBeforeEnterHook, AddBeforeUpdateHook, AddBeforeLeaveHook, AddAfterEnterHook, AddAfterUpdateHook, AddAfterLeaveHook, ErrorHookRunner, AddErrorHook, BeforeEnterHook, BeforeUpdateHook, BeforeLeaveHook, AfterEnterHook, AfterUpdateHook, AfterLeaveHook, RejectionHookRunner, RejectionHook, AddRejectionHook } from '@/types/hooks' import { getRouteHookCondition } from '@/services/hooks' import { ContextPushError } from '@/errors/contextPushError' import { ContextRejectionError } from '@/errors/contextRejectionError' import { ContextAbortError } from '@/errors/contextAbortError' import { getAfterHooksFromRoutes, getBeforeHooksFromRoutes } from '@/services/getRouteHooks' import { getGlobalAfterHooks, getGlobalBeforeHooks } from '@/services/getGlobalRouteHooks' import { getRejectionHooksFromRejection } from '@/services/getRejectionHooks' import { createVueAppStore, HasVueAppStore } from '@/services/createVueAppStore' import { createRouterKeyStore } from '@/services/createRouterKeyStore' import { Hooks } from '@/models/hooks' import { createRouterCallbackContext } from '@/services/createRouterCallbackContext' import { ContextError } from '@/errors/contextError' import { createRouteHooks } from '@/services/createRouteHooks' import { ResolvedRoute } from '@/types/resolved' import { MaybePromise } from '@/types/utilities' import { RedirectHook } from '@/types/redirects' export const getRouterHooksKey = createRouterKeyStore() export type RouterHooks = HasVueAppStore & { runBeforeRouteHooks: BeforeHookRunner, runAfterRouteHooks: AfterHookRunner, runErrorHooks: ErrorHookRunner, runRejectionHooks: RejectionHookRunner, addComponentHook: AddComponentHook, addGlobalRouteHooks: AddGlobalHooks, onBeforeRouteEnter: AddBeforeEnterHook, onBeforeRouteUpdate: AddBeforeUpdateHook, onBeforeRouteLeave: AddBeforeLeaveHook, onAfterRouteEnter: AddAfterEnterHook, onAfterRouteUpdate: AddAfterUpdateHook, onAfterRouteLeave: AddAfterLeaveHook, onError: AddErrorHook, onRejection: AddRejectionHook, } export function createRouterHooks(): RouterHooks { const { setVueApp, runWithContext } = createVueAppStore() const { store: globalStore, ...globalHooks } = createRouteHooks() const componentStore = new Hooks() const runBeforeRouteHooks: BeforeHookRunner = async ({ to, from }) => { const { reject, push, replace, update, abort } = createRouterCallbackContext({ to }) const routeHooks = getBeforeHooksFromRoutes(to, from) const globalHooks = getGlobalBeforeHooks(to, from, globalStore) const allHooks: (RedirectHook | BeforeEnterHook | BeforeUpdateHook | BeforeLeaveHook)[] = [ ...routeHooks.redirects, ...globalHooks.onBeforeRouteEnter, ...routeHooks.onBeforeRouteEnter, ...globalHooks.onBeforeRouteUpdate, ...routeHooks.onBeforeRouteUpdate, ...componentStore.onBeforeRouteUpdate, ...globalHooks.onBeforeRouteLeave, ...routeHooks.onBeforeRouteLeave, ...componentStore.onBeforeRouteLeave, ] try { const results = allHooks.map((callback) => { return runWithContext(() => callback(to, { // From could be null but leave hooks require that from is not null. If from is null there will be no leave hooks so this cast is purely to satisfy the type checker. from: from as ResolvedRoute, reject, push, replace, update, abort, })) }) await Promise.all(results) } catch (error) { if (error instanceof ContextPushError) { return error.response } if (error instanceof ContextRejectionError) { return error.response } if (error instanceof ContextAbortError) { return error.response } try { runErrorHooks(error, { to, from, source: 'hook' }) } catch (error) { if (error instanceof ContextPushError) { return error.response } if (error instanceof ContextRejectionError) { return error.response } throw error } } return { status: 'SUCCESS', } } const runAfterRouteHooks: AfterHookRunner = async ({ to, from }) => { const { reject, push, replace, update } = createRouterCallbackContext({ to }) const routeHooks = getAfterHooksFromRoutes(to, from) const globalHooks = getGlobalAfterHooks(to, from, globalStore) const allHooks: (AfterLeaveHook | AfterUpdateHook | AfterEnterHook)[] = [ ...componentStore.onAfterRouteLeave, ...routeHooks.onAfterRouteLeave, ...globalHooks.onAfterRouteLeave, ...componentStore.onAfterRouteUpdate, ...routeHooks.onAfterRouteUpdate, ...globalHooks.onAfterRouteUpdate, ...componentStore.onAfterRouteEnter, ...routeHooks.onAfterRouteEnter, ...globalHooks.onAfterRouteEnter, ] try { const results = allHooks.map((callback) => { return runWithContext(() => callback(to, { // From could be null but leave hooks require that from is not null. If from is null there will be no leave hooks so this cast is purely to satisfy the type checker. from: from as ResolvedRoute, reject, push, replace, update, })) }) await Promise.all(results) } catch (error) { if (error instanceof ContextPushError) { return error.response } if (error instanceof ContextRejectionError) { return error.response } try { runErrorHooks(error, { to, from, source: 'hook' }) } catch (error) { if (error instanceof ContextPushError) { return error.response } if (error instanceof ContextRejectionError) { return error.response } throw error } } return { status: 'SUCCESS', } } const runErrorHooks: ErrorHookRunner = (error, { to, from, source }) => { const { reject, push, replace, update } = createRouterCallbackContext({ to }) for (const hook of globalStore.onError) { try { hook(error, { to, from, source, reject, push, replace, update }) return } catch (hookError) { if (hookError instanceof ContextError) { throw hookError } if (hookError === error) { // Hook re-threw the same error, continue to next hook continue } throw hookError } } } const runRejectionHooks: RejectionHookRunner = (rejection, { to, from }) => { const rejectionHooks = getRejectionHooksFromRejection(rejection) const allHooks: RejectionHook[] = [ ...rejectionHooks.onRejection, ...globalStore.onRejection, ] for (const hook of allHooks) { hook(rejection.type, { to, from }) } } const addComponentHook: AddComponentHook = ({ lifecycle, depth, hook }) => { const condition = getRouteHookCondition(lifecycle) const hooks = componentStore[lifecycle] // Using `any` here for context because its just passed through to the hook and typing it is more complex than it's worth const wrapped = (to: ResolvedRoute, context: any): MaybePromise => { if (!condition(to, context.from, depth)) { return } return hook(to, context) } hooks.add(wrapped) return () => hooks.delete(wrapped) } const addGlobalRouteHooks: AddGlobalHooks = (hooks) => { hooks.onBeforeRouteEnter.forEach((hook) => globalHooks.onBeforeRouteEnter(hook)) hooks.onBeforeRouteUpdate.forEach((hook) => globalHooks.onBeforeRouteUpdate(hook)) hooks.onBeforeRouteLeave.forEach((hook) => globalHooks.onBeforeRouteLeave(hook)) hooks.onAfterRouteEnter.forEach((hook) => globalHooks.onAfterRouteEnter(hook)) hooks.onAfterRouteUpdate.forEach((hook) => globalHooks.onAfterRouteUpdate(hook)) hooks.onAfterRouteLeave.forEach((hook) => globalHooks.onAfterRouteLeave(hook)) hooks.onError.forEach((hook) => globalHooks.onError(hook)) } return { runBeforeRouteHooks, runAfterRouteHooks, runErrorHooks, runRejectionHooks, addComponentHook, addGlobalRouteHooks, setVueApp, ...globalHooks, } } ================================================ FILE: src/services/createRouterKeyStore.ts ================================================ import { Router } from '@/types/router' import { InjectionKey } from 'vue' export function createRouterKeyStore() { const lookup = new Map, InjectionKey>() return (routerKey: InjectionKey): InjectionKey => { const key = lookup.get(routerKey) if (!key) { const key = Symbol() lookup.set(routerKey, key) return key } return key } } ================================================ FILE: src/services/createRouterPlugin.browser.spec.ts ================================================ import { test, expect, vi } from 'vitest' import { createRoute } from './createRoute' import { createRouter } from './createRouter' import { createRouterPlugin } from './createRouterPlugin' import { component, routes } from '@/utilities/testHelpers' import { mount, flushPromises } from '@vue/test-utils' import { createRejection } from './createRejection' test('given a plugin, adds the routes to the router', async () => { const pluginRejection = createRejection({ type: 'plugin', component, }) const plugin = createRouterPlugin({ routes: [createRoute({ name: 'plugin', path: '/plugin', component })], rejections: [pluginRejection], }) const router = createRouter([], { initialUrl: '/plugin' }, [plugin]) await router.start() expect(router.route.name).toBe('plugin') }) test('given a plugin, adds the rejections to the router', async () => { const pluginRejection = createRejection({ type: 'from-plugin', component: { template: '
This is a plugin rejection
' }, }) const plugin = createRouterPlugin({ rejections: [pluginRejection], }) const router = createRouter([], { initialUrl: '/' }, [plugin]) await router.start() const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) router.reject('from-plugin') await flushPromises() expect(wrapper.html()).toBe('
This is a plugin rejection
') }) test('given a plugin, adds the hooks to the router', async () => { const onBeforeRouteEnterMock = vi.fn() const onBeforeRouteUpdateMock = vi.fn() const onBeforeRouteLeaveMock = vi.fn() const onAfterRouteEnterMock = vi.fn() const onAfterRouteUpdateMock = vi.fn() const onAfterRouteLeaveMock = vi.fn() const plugin = createRouterPlugin({}) plugin.onBeforeRouteEnter(onBeforeRouteEnterMock) plugin.onBeforeRouteUpdate(onBeforeRouteUpdateMock) plugin.onBeforeRouteLeave(onBeforeRouteLeaveMock) plugin.onAfterRouteEnter(onAfterRouteEnterMock) plugin.onAfterRouteUpdate(onAfterRouteUpdateMock) plugin.onAfterRouteLeave(onAfterRouteLeaveMock) const router = createRouter(routes, { initialUrl: '/parentA/valueA' }, [plugin]) expect(onBeforeRouteEnterMock).toHaveBeenCalledTimes(0) expect(onBeforeRouteUpdateMock).toHaveBeenCalledTimes(0) expect(onBeforeRouteLeaveMock).toHaveBeenCalledTimes(0) expect(onAfterRouteLeaveMock).toHaveBeenCalledTimes(0) expect(onAfterRouteUpdateMock).toHaveBeenCalledTimes(0) expect(onAfterRouteEnterMock).toHaveBeenCalledTimes(0) await router.start() expect(onBeforeRouteEnterMock).toHaveBeenCalledTimes(1) expect(onBeforeRouteUpdateMock).toHaveBeenCalledTimes(0) expect(onBeforeRouteLeaveMock).toHaveBeenCalledTimes(0) expect(onAfterRouteLeaveMock).toHaveBeenCalledTimes(0) expect(onAfterRouteUpdateMock).toHaveBeenCalledTimes(0) expect(onAfterRouteEnterMock).toHaveBeenCalledTimes(1) await router.push('parentA.childA', { paramA: 'valueA', paramB: 'valueB' }) expect(onBeforeRouteEnterMock).toHaveBeenCalledTimes(2) expect(onBeforeRouteUpdateMock).toHaveBeenCalledTimes(1) expect(onBeforeRouteLeaveMock).toHaveBeenCalledTimes(0) expect(onAfterRouteLeaveMock).toHaveBeenCalledTimes(0) expect(onAfterRouteUpdateMock).toHaveBeenCalledTimes(1) expect(onAfterRouteEnterMock).toHaveBeenCalledTimes(2) await router.push('parentA.childB', { paramA: 'valueB', paramD: 'valueD' }) expect(onBeforeRouteEnterMock).toHaveBeenCalledTimes(3) expect(onBeforeRouteUpdateMock).toHaveBeenCalledTimes(2) expect(onBeforeRouteLeaveMock).toHaveBeenCalledTimes(1) expect(onAfterRouteLeaveMock).toHaveBeenCalledTimes(1) expect(onAfterRouteUpdateMock).toHaveBeenCalledTimes(2) expect(onAfterRouteEnterMock).toHaveBeenCalledTimes(3) await router.push('parentB') expect(onBeforeRouteEnterMock).toHaveBeenCalledTimes(4) expect(onBeforeRouteUpdateMock).toHaveBeenCalledTimes(2) expect(onBeforeRouteLeaveMock).toHaveBeenCalledTimes(2) expect(onAfterRouteLeaveMock).toHaveBeenCalledTimes(2) expect(onAfterRouteUpdateMock).toHaveBeenCalledTimes(2) expect(onAfterRouteEnterMock).toHaveBeenCalledTimes(4) }) ================================================ FILE: src/services/createRouterPlugin.spec-d.ts ================================================ import { describe, expectTypeOf, test } from 'vitest' import { routes } from '@/utilities/testHelpers' import { createRouterPlugin } from './createRouterPlugin' import { createRejection } from './createRejection' import { ResolvedRoute } from '@/types/resolved' import { RouterReject } from '@/types/routerReject' import { RouterPush } from '@/types/routerPush' import { RouterReplace } from '@/types/routerReplace' describe('hooks', () => { const NotAuthorized = createRejection({ type: 'NotAuthorized', }) const plugin = createRouterPlugin({ routes, rejections: [NotAuthorized], }) test('to and from should not be specifically typed', () => { plugin.onBeforeRouteEnter((to, { from }) => { expectTypeOf(to).toEqualTypeOf() expectTypeOf(from).toEqualTypeOf() }) }) test('reject should be correctly typed', () => { plugin.onBeforeRouteEnter((_to, { reject }) => { expectTypeOf(reject).toEqualTypeOf>() }) }) test('push should be correctly typed', () => { plugin.onBeforeRouteEnter((_to, { push }) => { expectTypeOf(push).toEqualTypeOf>() }) }) test('replace should be correctly typed', () => { plugin.onBeforeRouteEnter((_to, { replace }) => { expectTypeOf(replace).toEqualTypeOf>() }) }) }) ================================================ FILE: src/services/createRouterPlugin.ts ================================================ import { CreateRouterPluginOptions, RouterPlugin, PluginRouteHooks, RouterPluginInternal, IS_ROUTER_PLUGIN_SYMBOL } from '@/types/routerPlugin' import { createRouteHooks } from '@/services/createRouteHooks' import { Rejections } from '@/types/rejection' import { Routes } from '@/types/route' export function createRouterPlugin< TRoutes extends Routes = [], TRejections extends Rejections = [] >(plugin: CreateRouterPluginOptions): RouterPlugin & PluginRouteHooks export function createRouterPlugin(options: CreateRouterPluginOptions): RouterPlugin { const { store, ...hooks } = createRouteHooks() const internal = { [IS_ROUTER_PLUGIN_SYMBOL]: true, hooks: store, } satisfies RouterPluginInternal const plugin = { routes: options.routes ?? [], rejections: options.rejections ?? [], ...hooks, ...internal, } satisfies RouterPlugin & RouterPluginInternal & PluginRouteHooks return plugin } ================================================ FILE: src/services/createRouterRoute.spec.ts ================================================ import { expect, test, vi } from 'vitest' import { reactive } from 'vue' import { createRouterRoute, isRouterRoute } from '@/services/createRouterRoute' import { createRoute } from './createRoute' import { createResolvedRoute } from './createResolvedRoute' test('isRouterRoute returns correct response', () => { const route = createRoute({ name: 'isRouterRoute' }) const resolved = createResolvedRoute(route, {}) const push = vi.fn() const routerKey = Symbol() const routerRoute = createRouterRoute(routerKey, reactive(resolved), push) expect(isRouterRoute(routerKey, routerRoute)).toBe(true) expect(isRouterRoute(routerKey, {})).toBe(false) }) test('sending state, includes state in push options', () => { const route = createRoute({ name: 'state' }) const resolved = createResolvedRoute(route, {}) const push = vi.fn() const routerKey = Symbol() const routerRoute = createRouterRoute(routerKey, reactive(resolved), push) routerRoute.update({}, { state: { foo: 'foo' } }) expect(push).toBeCalledWith( 'state', {}, { state: { foo: 'foo' } }, ) routerRoute.update('param', 123, { state: { bar: 'bar' } }) expect(push).toBeCalledWith( 'state', { param: 123 }, { state: { bar: 'bar' } }, ) }) ================================================ FILE: src/services/createRouterRoute.ts ================================================ import { computed, InjectionKey, reactive, toRefs } from 'vue' import { parseUrl, stringifyUrl, updateUrl } from '@/services/urlParser' import { ResolvedRoute } from '@/types/resolved' import { RouterRoute } from '@/types/routerRoute' import { RouterPush, RouterPushOptions } from '@/types/routerPush' import { QuerySource } from '@/types/querySource' import { Router } from '@/types/router' import { isPropertyKey } from '@/utilities/guards' const isRouterRouteSymbol = Symbol('isRouterRouteSymbol') export function isRouterRoute(routerKey: InjectionKey, value: unknown): value is RouterRoute { return typeof value === 'object' && value !== null && isRouterRouteSymbol in value && routerKey in value } export function createRouterRoute(routerKey: InjectionKey, route: TRoute, push: RouterPush): RouterRoute { function updateQuery(query: QuerySource): void { const routeWithoutQuery = stringifyUrl({ ...parseUrl(route.href), query: undefined, }) const updatedHref = updateUrl(routeWithoutQuery, { query }) push(updatedHref) } function update(individualParamNameOrAllParams: PropertyKey | Partial, valueOrOptions?: any, maybeOptions?: RouterPushOptions): Promise { if (isPropertyKey(individualParamNameOrAllParams)) { return update({ [individualParamNameOrAllParams]: valueOrOptions }, maybeOptions) } const paramUpdates = individualParamNameOrAllParams const options = valueOrOptions const params = { ...route.params, ...paramUpdates, } return push(route.name, params, options) } const querySet: URLSearchParams['set'] = (...parameters) => { const query = new URLSearchParams(route.query) query.set(...parameters) updateQuery(query) } const queryAppend: URLSearchParams['append'] = (...parameters) => { const query = new URLSearchParams(route.query) query.append(...parameters) updateQuery(query) } const queryDelete: URLSearchParams['delete'] = (...parameters) => { const query = new URLSearchParams(route.query) query.delete(...parameters) updateQuery(query) } const { id, matched, matches, name, hash, href } = toRefs(route) const paramsProxy = new Proxy({}, { get(_target, property, receiver) { return Reflect.get(route.params, property, receiver) }, set(_target, property, value) { update(property, value) return true }, ownKeys() { return Reflect.ownKeys(route.params) }, getOwnPropertyDescriptor(_target, prop) { return Reflect.getOwnPropertyDescriptor(route.params, prop) }, }) const params = computed({ get() { return paramsProxy }, set(params) { update(params) }, }) const query = computed({ get() { return new Proxy(route.query, { get(target, property, receiver) { switch (property) { case 'append': return queryAppend case 'set': return querySet case 'delete': return queryDelete default: return Reflect.get(target, property, receiver) } }, }) }, set(query: QuerySource) { updateQuery(query) }, }) const state = computed({ get() { return new Proxy(route.state, { set(_target, property, value) { update({}, { state: { ...route.state, [property]: value } }) return true }, }) }, set(state) { update({}, { state }) }, }) const routerRoute: RouterRoute = reactive({ ...route, id, matched, matches, state, query, hash, params, name, href, update, [isRouterRouteSymbol]: true, [routerKey]: true, }) return routerRoute } ================================================ FILE: src/services/createUniqueIdSequence.ts ================================================ export function createUniqueIdSequence(): () => string { let currentId = 0 return () => (++currentId).toString() } const FIRST_SEQUENCE_ID = createUniqueIdSequence()() export function isFirstUniqueSequenceId(id: string): boolean { return id === FIRST_SEQUENCE_ID } ================================================ FILE: src/services/createUrl.spec.ts ================================================ import { describe, expect, test, vi } from 'vitest' import { createUrl } from '@/services/createUrl' import { withParams } from './withParams' import { withDefault } from './withDefault' import { InvalidRouteParamValueError } from '@/errors/invalidRouteParamValueError' import { createParam } from './createParam' test('given a query that starts with "?", strips the leading "?"', () => { const url = createUrl({ query: '?foo=123' }) expect(url.stringify()).toBe('/?foo=123') }) test('given a hash that starts with "#", strips the leading "#"', () => { const url = createUrl({ hash: '#foo' }) expect(url.stringify()).toBe('/#foo') }) describe('stringify', () => { test('given parts without host, protocol, or path, returns forward slash to satisfy Url', () => { const url = createUrl({}) expect(url.stringify()).toBe('/') }) test.each(['foo', '/foo'])('given parts with path, returns value with path', (path) => { const parts = { host: 'https://kitbag.dev', path, } const url = createUrl(parts) expect(url.stringify()).toBe('https://kitbag.dev/foo') }) test.each(['?bar=123', 'bar=123'])('given parts with query, returns value with query', (query) => { const parts = { host: 'https://kitbag.dev', query, } const url = createUrl(parts) expect(url.stringify()).toBe('https://kitbag.dev/?bar=123') }) test.each(['bar', '#bar'])('given parts with hash, returns value with hash', (hash) => { const parts = { host: 'https://kitbag.dev', hash, } const url = createUrl(parts) expect(url.stringify()).toBe('https://kitbag.dev/#bar') }) test('given parts without host, returns url starting with forward slash', () => { const parts = { path: '/foo', query: '?bar=123', } const url = createUrl(parts) expect(url.stringify()).toBe('/foo?bar=123') }) test.each([ { host: 'https://router.kitbag.dev' }, { host: 'https://github.io' }, ])('given parts with host, returns value that satisfies Url', (parts) => { const url = createUrl(parts) expect(url.stringify()).toBe(`${parts.host}/`) }) }) describe('parse', () => { test.each([ ['/[foo]', '/', 'Param is not optional, received ""'], [withParams('/[foo]', { foo: Number }), '/abc', 'Expected number value, received "abc"'], [withParams('/[foo]', { foo: Boolean }), '/abc', 'Expected boolean value, received "abc"'], [withParams('/[foo]', { foo: Date }), '/abc', 'Expected date value, received "abc"'], [withParams('/[foo]', { foo: JSON }), '/abc', 'Expected JSON value, received "abc"'], [withParams('/[foo]', { foo: /[\d{3}]/ }), '/abc', 'Expected value to match regex /[\\d{3}]/, received "abc"'], [withParams('/[foo]', { foo: 'def' }), '/abc', 'Expected value to be def, received "abc"'], ])('given invalid url $1, throws InvalidRouteParamValueError with useful error message $2', (path, input, output) => { const url = createUrl({ path, }) expect(() => url.parse(input)).toThrowError(output) }) test('given params in each part of the URL, extracts them', () => { const input = 'foo' const url = createUrl({ host: 'https://[inHost].dev', path: '/[inPath]', query: 'inQuery=[inQuery]', hash: '[inHash]', }) const response = url.parse(`https://${input}.dev/${input}?inQuery=${input}#${input}`) expect(response.inHost).toBe(input) expect(response.inPath).toBe(input) expect(response.inQuery).toBe(input) expect(response.inHash).toBe(`#${input}`) }) test('given value with encoded URL characters, decodes those characters', () => { const escapeCodes = [ { decoded: ' ', encoded: '%20' }, { decoded: '<', encoded: '%3C' }, { decoded: '>', encoded: '%3E' }, { decoded: '#', encoded: '%23' }, { decoded: '%', encoded: '%25' }, { decoded: '{', encoded: '%7B' }, { decoded: '}', encoded: '%7D' }, { decoded: '|', encoded: '%7C' }, { decoded: '\\', encoded: '%5C' }, { decoded: '^', encoded: '%5E' }, { decoded: '~', encoded: '%7E' }, { decoded: '[', encoded: '%5B' }, { decoded: ']', encoded: '%5D' }, { decoded: '`', encoded: '%60' }, { decoded: ';', encoded: '%3B' }, { decoded: '?', encoded: '%3F' }, { decoded: ':', encoded: '%3A' }, { decoded: '@', encoded: '%40' }, { decoded: '=', encoded: '%3D' }, { decoded: '&', encoded: '%26' }, { decoded: '$', encoded: '%24' }, ] const input = escapeCodes.map((code) => code.encoded).join('') const output = escapeCodes.map((code) => code.decoded).join('') const url = createUrl({ path: '/[inPath]', query: 'inQuery=[inQuery]', hash: '[inHash]', }) const response = url.parse(`/${input}?inQuery=${input}#${input}`) expect(response.inPath).toBe(output) expect(response.inQuery).toBe(output) expect(response.inHash).toBe(`#${output}`) }) test('given url with query param that has a different param name than query key, still works as expected', () => { const url = createUrl({ path: '/', query: 's=[?search]', }) const response = url.parse('/?s=foo') expect(response.search).toBe('foo') }) }) describe('url assembly', () => { describe('path params', () => { test.each([ ['/simple'], [withParams('/simple', {})], ])('given simple route with string path and without params, returns route path', (path) => { const url = createUrl({ name: 'simple', path, }) const response = url.stringify() expect(response).toBe('/simple') }) test.each([ ['/simple/[?simple]'], [withParams('/simple/[?simple]', { simple: String })], [withParams('/simple/[simple]', { simple: withDefault(String, 'abc') })], ])('given route with optional string param NOT provided, returns route Path with string without values interpolated', (path) => { const url = createUrl({ name: 'simple', path, }) const response = url.stringify() expect(response).toBe('/simple/') }) test.each([ ['/simple/[?simple]'], [withParams('/simple/[?simple]', { simple: String })], ])('given route with optional string param provided, returns route Path with string with values interpolated', (path) => { const url = createUrl({ name: 'simple', path, }) const response = url.stringify({ simple: 'ABC', }) expect(response).toBe('/simple/ABC') }) test('given route with default string param provided, returns route Path with string with values interpolated', () => { const url = createUrl({ name: 'simple', path: withParams('/simple/[simple]', { simple: withDefault(String, 'abc') }), }) const response = url.stringify({ simple: 'DEF', }) expect(response).toBe('/simple/DEF') }) test.each([ ['/simple/[simple]'], [withParams('/simple/[simple]', { simple: String })], ])('given route with required string param NOT provided, throws InvalidRouteParamValueError', (path) => { const url = createUrl({ name: 'simple', path, }) expect(() => url.stringify()).toThrowError(InvalidRouteParamValueError) }) test.each([ ['/simple/[simple]'], [withParams('/simple/[simple]', { simple: String })], ])('given route with required string param provided, returns route Path with string with values interpolated', (path) => { const url = createUrl({ name: 'simple', path, }) const response = url.stringify({ simple: 'ABC', }) expect(response).toBe('/simple/ABC') }) }) describe('query params', () => { test.each([ ['simple=abc'], [withParams('simple=abc', {})], ])('given simple route with string query and without params, returns route query', (query) => { const url = createUrl({ name: 'simple', path: '/', query, }) const response = url.stringify() expect(response).toBe('/?simple=abc') }) test.each([ ['simple=[?simple]'], [withParams('simple=[?simple]', { simple: String })], [withParams('simple=[simple]', { simple: withDefault(String, 'abc') })], ])('given route with optional param NOT provided, leaves entire key off', (query) => { const url = createUrl({ name: 'simple', path: '/', query, }) const response = url.stringify() expect(response).toBe('/') }) test.each([ ['simple=[?simple]'], [withParams('simple=[?simple]', { simple: String })], ])('given route with optional string param provided but empty, returns route Query with string without values interpolated', (query) => { const url = createUrl({ name: 'simple', path: '/', query, }) const response = url.stringify({ simple: '' }) expect(response).toBe('/?simple=') }) test('given route with default string param provided but empty, returns route Query with string without values interpolated', () => { const url = createUrl({ name: 'simple', path: '/', query: withParams('simple=[simple]', { simple: withDefault(String, 'abc') }), }) const response = url.stringify({ simple: '' }) expect(response).toBe('/?simple=') }) test.each([ ['simple=[?simple]'], [withParams('simple=[?simple]', { simple: String })], ])('given route with optional string param provided, returns route Query with string with values interpolated', (query) => { const url = createUrl({ name: 'simple', path: '/', query, }) const response = url.stringify({ simple: 'ABC', }) expect(response).toBe('/?simple=ABC') }) test('given route with default string param provided, returns route Query with string with values interpolated', () => { const url = createUrl({ name: 'simple', path: '/', query: withParams('simple=[simple]', { simple: withDefault(String, 'abc') }), }) const response = url.stringify({ simple: 'DEF', }) expect(response).toBe('/?simple=DEF') }) test.each([ ['simple=[simple]'], [withParams('simple=[simple]', { simple: String })], ])('given route with required string param NOT provided, throws InvalidRouteParamValueError', (query) => { const url = createUrl({ name: 'simple', path: '/', query, }) expect(() => url.stringify()).toThrowError(InvalidRouteParamValueError) }) test.each([ ['simple=[simple]'], [withParams('simple=[simple]', { simple: String })], ])('given route with required string param provided, returns route Query with string with values interpolated', (query) => { const url = createUrl({ name: 'simple', path: '/', query, }) const response = url.stringify({ simple: 'ABC', }) expect(response).toBe('/?simple=ABC') }) test('given route with optional custom param, finds and uses param to set value', () => { const randomValue = Math.floor(Math.random() * 1000) const get = vi.fn() const set = vi.fn().mockReturnValue(randomValue.toString()) const myParam = createParam({ get, set }) const url = createUrl({ name: 'simple', path: '/', query: withParams('sort=[?sort]', { sort: myParam }), }) const response = url.stringify({ sort: 'irrelevant' }) expect(response).toBe(`/?sort=${randomValue}`) }) test('given route with query params with different param names than query keys, still works as expected', () => { const url = createUrl({ name: 'query-keys', path: '/', query: 's=[?search]', }) const response = url.stringify({ search: 'foo' }) expect(response).toBe('/?s=foo') }) test('given route with query params defined in a record, returns route Query with string with values interpolated', () => { const url = createUrl({ name: 'simple', path: '/', query: { s: 'ABC' }, }) const response = url.stringify() expect(response).toBe('/?s=ABC') }) // todo withDefault doesn't work type-wise test('given route with query params defined in a record, returns route Query with string with values interpolated', () => { const url = createUrl({ name: 'simple', path: '/', query: { s: Boolean }, }) const response = url.stringify({ s: true }) expect(response).toBe('/?s=true') }) test('given route with query params defined in a array, returns route Query with string with values interpolated', () => { const url = createUrl({ name: 'simple', path: '/', query: [['s', 'ABC']], }) const response = url.stringify() expect(response).toBe('/?s=ABC') }) test('given route with query params defined in a array, returns route Query with string with values interpolated', () => { const url = createUrl({ name: 'simple', path: '/', query: [['s', Boolean]], }) const response = url.stringify({ s: true }) expect(response).toBe('/?s=true') }) }) describe('host params', () => { test.each([ ['https://kitbag.dev'], [withParams('https://kitbag.dev', {})], ])('given simple route with string host and without params, returns route host', (host) => { const url = createUrl({ name: 'simple', path: '/', host, }) const response = url.stringify() expect(response).toBe('https://kitbag.dev/') }) test.each([ ['https://[?subdomain]kitbag.dev'], [withParams('https://[?subdomain]kitbag.dev', { subdomain: String })], [withParams('https://[?subdomain]kitbag.dev', { subdomain: withDefault(String, 'abc') })], ])('given route with optional param NOT provided, leaves entire key off', (host) => { const url = createUrl({ name: 'simple', path: '/', host, }) const response = url.stringify() expect(response).toBe('https://kitbag.dev/') }) test.each([ ['https://[?subdomain]kitbag.dev'], [withParams('https://[?subdomain]kitbag.dev', { subdomain: String })], ])('given route with optional string param provided, returns route Host with string with values interpolated', (host) => { const url = createUrl({ name: 'simple', path: '/', host, }) const response = url.stringify({ subdomain: 'ABC.', }) expect(response).toBe('https://abc.kitbag.dev/') }) test('given route with default string param provided, returns route Host with string with values interpolated', () => { const url = createUrl({ name: 'simple', path: '/', host: withParams('https://[?subdomain]kitbag.dev', { subdomain: withDefault(String, 'abc.') }), }) const response = url.stringify({ subdomain: 'DEF.', }) expect(response).toBe('https://def.kitbag.dev/') }) test.each([ ['https://[subdomain]kitbag.dev'], [withParams('https://[subdomain]kitbag.dev', { subdomain: String })], ])('given route with required string param NOT provided, throws InvalidRouteParamValueError', (host) => { const url = createUrl({ name: 'simple', path: '/', host, }) expect(() => url.stringify()).toThrowError(InvalidRouteParamValueError) }) test.each([ ['https://[subdomain]kitbag.dev'], [withParams('https://[subdomain]kitbag.dev', { subdomain: String })], ])('given route with required string param provided, returns route Host with string with values interpolated', (host) => { const url = createUrl({ name: 'simple', path: '/', host, }) const response = url.stringify({ subdomain: 'ABC.', }) expect(response).toBe('https://abc.kitbag.dev/') }) }) describe('hash params', () => { test.each([ ['foo'], [withParams('foo', {})], ])('given simple route with string hash and without params, returns route hash', (hash) => { const url = createUrl({ name: 'simple', path: '/', hash, }) const response = url.stringify() expect(response).toBe('/#foo') }) test.each([ ['foo[?bar]'], [withParams('foo[?bar]', { bar: String })], [withParams('foo[bar]', { bar: withDefault(String, 'abc') })], ])('given route with optional param NOT provided, returns route hash with string without values interpolated', (hash) => { const url = createUrl({ name: 'simple', path: '/', hash, }) const response = url.stringify() expect(response).toBe('/#foo') }) test.each([ ['foo[?bar]'], [withParams('foo[?bar]', { bar: String })], ])('given route with optional string param provided, returns route Hash with string with values interpolated', (hash) => { const url = createUrl({ name: 'simple', path: '/', hash, }) const response = url.stringify({ bar: 'ABC.', }) expect(response).toBe('/#fooABC.') }) test('given route with default string param provided, returns route Hash with string with values interpolated', () => { const url = createUrl({ name: 'simple', path: '/', hash: withParams('foo[bar]', { bar: withDefault(String, 'abc.') }), }) const response = url.stringify({ bar: 'DEF.', }) expect(response).toBe('/#fooDEF.') }) test.each([ ['foo[bar]'], [withParams('foo[bar]', { bar: String })], ])('given route with required string param NOT provided, throws InvalidRouteParamValueError', (hash) => { const url = createUrl({ name: 'simple', path: '/', hash, }) expect(() => url.stringify()).toThrowError(InvalidRouteParamValueError) }) test.each([ ['foo[bar]'], [withParams('foo[bar]', { bar: String })], ])('given route with required string param provided, returns route Hash with string with values interpolated', (hash) => { const url = createUrl({ name: 'simple', path: '/', hash, }) const response = url.stringify({ bar: 'ABC', }) expect(response).toBe('/#fooABC') }) }) test('given route without host that does not start with a forward slash, returns url with forward slash', () => { const url = createUrl({ name: 'invalid-relative-path', path: 'foo', }) const response = url.stringify() expect(response).toBe('/foo') }) test('given route with host and path without forward slash, returns forward slash after host', () => { const url = createUrl({ name: 'missing-delimiter-after-host', path: 'foo', host: 'https://kitbag.dev', }) const response = url.stringify() expect(response).toBe('https://kitbag.dev/foo') }) test('given route with host and path with excess forward slashes, returns forward slash after host', () => { const url = createUrl({ name: 'extra-delimiter-after-host', path: '/foo', host: 'https://kitbag.dev/', }) const response = url.stringify() expect(response).toBe('https://kitbag.dev/foo') }) }) ================================================ FILE: src/services/createUrl.ts ================================================ import { toUrlQueryPart, toUrlPart, UrlPart } from '@/services/withParams' import { getParamValueFromUrl, setParamValueOnUrl } from '@/services/paramsFinder' import { getParamName, generateRouteHostRegexPattern, generateRoutePathRegexPattern, generateRouteQueryRegexPatterns, generateRouteHashRegexPattern } from '@/services/routeRegex' import { getParamValue, setParamValue } from '@/services/params' import { parseUrl, stringifyUrl } from '@/services/urlParser' import { IS_URL_SYMBOL, CreateUrlOptions, ToUrl, Url, ParseUrlOptions, UrlInternal } from '@/types/url' import { UrlString } from '@/types/urlString' import { checkDuplicateParams } from '@/utilities/checkDuplicateParams' import { stringHasValue } from '@/utilities/guards' export function createUrl(options: T): ToUrl export function createUrl(urlOrOptions: CreateUrlOptions): Url { const options = { host: toUrlPart(urlOrOptions.host), path: toUrlPart(urlOrOptions.path), query: cleanQuery(toUrlQueryPart(urlOrOptions.query)), hash: cleanHash(toUrlPart(urlOrOptions.hash)), } checkDuplicateParams(options.path.params, options.query.params, options.host.params, options.hash.params) const host = { ...options.host, regexp: generateRouteHostRegexPattern(options.host.value), stringify(params: Record = {}): string { return assembleParamValues(options.host, params) }, } const path = { ...options.path, regexp: { trailingSlashIgnored: generateRoutePathRegexPattern(options.path.value), trailingSlashRemoved: generateRoutePathRegexPattern(options.path.value.replace(/^(.+)\/$/, '$1')), }, stringify(params: Record = {}): string { return assembleParamValues(options.path, params) }, } const query = { ...options.query, regexp: generateRouteQueryRegexPatterns(options.query.value), stringify(params: Record = {}): string { return assembleQueryParamValues(options.query, params).toString() }, } const hash = { ...options.hash, regexp: generateRouteHashRegexPattern(options.hash.value), stringify(params: Record = {}): string { return assembleParamValues(options.hash, params) }, } function stringify(params: Record = {}): UrlString { return stringifyUrl({ host: host.stringify(params), path: path.stringify(params), query: query.stringify(params), hash: hash.stringify(params), }) } function checkMatchesRegex(url: string, options: ParseUrlOptions = {}): void { const parts = parseUrl(url) const shouldRemoveTrailingSlashes = options.removeTrailingSlashes ?? true if (!host.regexp.test(parts.host ?? '')) { throw new Error('Host does not match') } const pathRegex = shouldRemoveTrailingSlashes ? path.regexp.trailingSlashRemoved : path.regexp.trailingSlashIgnored if (!pathRegex.test(parts.path)) { throw new Error('Path does not match') } const queryString = parts.query.toString() if (!query.regexp.every((pattern) => pattern.test(queryString))) { throw new Error('Query does not match') } if (!hash.regexp.test(parts.hash)) { throw new Error('Hash does not match') } } function parse(url: string, options: ParseUrlOptions = {}): Record { checkMatchesRegex(url, options) const parts = parseUrl(url) return { ...getParams(host, parts.host ?? ''), ...getParams(path, parts.path), ...getQueryParams(query, parts.query.toString()), ...getParams(hash, parts.hash), } } function tryParse(url: string, options: ParseUrlOptions = {}): { success: true, params: Record } | { success: false, params: {}, error: Error } { try { return { success: true, params: parse(url, options) } } catch (cause) { return { success: false, params: {}, error: new Error('Failed to parse url', { cause }) } } } const internal = { [IS_URL_SYMBOL]: true, schema: { host, path, query, hash }, params: {}, } as const satisfies UrlInternal const url = { ...internal, isRelative: !stringHasValue(options.host.value), stringify, parse, tryParse, } satisfies Url & UrlInternal return url } function cleanHash(hash: UrlPart): UrlPart { return { ...hash, value: hash.value.replace(/^#/, ''), } } function cleanQuery(query: UrlPart): UrlPart { return { ...query, value: query.value.replace(/^\?/, ''), } } function assembleParamValues(part: UrlPart, paramValues: Record): string { return Object.keys(part.params).reduce((url, name) => { return setParamValueOnUrl(url, part, name, paramValues[name]) }, part.value) } function assembleQueryParamValues(query: UrlPart, paramValues: Record): URLSearchParams { const search = new URLSearchParams(query.value) if (!query.value) { return search } for (const [key, value] of Array.from(search.entries())) { const paramName = getParamName(value) const isNotParam = !paramName if (isNotParam) { continue } const paramValue = setParamValue(paramValues[paramName], query.params[paramName]) const valueNotProvidedAndNoDefaultUsed = paramValues[paramName] === undefined && paramValue === '' const shouldLeaveEmptyValueOut = query.params[paramName].isOptional && valueNotProvidedAndNoDefaultUsed if (shouldLeaveEmptyValueOut) { search.delete(key, value) } else { search.set(key, paramValue) } } return search } function getParams(path: UrlPart, url: string): Record { const values: Record = {} const decodedValueFromUrl = decodeURIComponent(url) for (const [name, urlParam] of Object.entries(path.params)) { const stringValue = getParamValueFromUrl(decodedValueFromUrl, path, name) const paramValue = getParamValue(stringValue, urlParam) values[name] = paramValue } return values } /** * This function has unique responsibilities not accounted for by getParams thanks to URLSearchParams * * 1. Find query values when other query params are omitted or in a different order * 2. Find query values based on the url search key, which might not match the param name */ function getQueryParams(query: UrlPart, url: string): Record { const values: Record = {} const routeSearch = new URLSearchParams(query.value) const actualSearch = new URLSearchParams(url) for (const [key, value] of Array.from(routeSearch.entries())) { const paramName = getParamName(value) const isNotParam = !paramName if (isNotParam) { continue } const valueOnUrl = actualSearch.get(key) ?? undefined const paramValue = getParamValue(valueOnUrl, query.params[paramName]) values[paramName] = paramValue } return values } ================================================ FILE: src/services/createVisibilityObserver.ts ================================================ import { isBrowser } from '@/utilities/isBrowser' import { reactive } from 'vue' export type VisibilityObserver = { observe: (element: Element) => void, unobserve: (element: Element) => void, disconnect: () => void, isElementVisible: (element: Element) => boolean, } export function createVisibilityObserver(): VisibilityObserver { const elements = reactive(new Map()) const observer = isBrowser() ? createObserver() : null const observe: VisibilityObserver['observe'] = (element) => { elements.set(element, false) observer?.observe(element) } const unobserve: VisibilityObserver['unobserve'] = (element) => { elements.delete(element) observer?.unobserve(element) } const disconnect: VisibilityObserver['disconnect'] = () => { observer?.disconnect() } const isElementVisible: VisibilityObserver['isElementVisible'] = (element) => { return elements.get(element) ?? false } function createObserver(): IntersectionObserver { return new IntersectionObserver((entries) => { entries.forEach((entry) => { elements.set(entry.target, entry.isIntersecting) }) }) } return { observe, unobserve, disconnect, isElementVisible, } } ================================================ FILE: src/services/createVueAppStore.ts ================================================ import { App } from 'vue' export type HasVueAppStore = { setVueApp: (app: App) => void, } type VueAppStore = HasVueAppStore & { runWithContext: (callback: () => T) => T, } export function createVueAppStore(): VueAppStore { let instance: App | null = null function setVueApp(app: App): void { instance = app } function runWithContext(callback: () => T): T { if (!instance) { return callback() } return instance.runWithContext(callback) } return { setVueApp, runWithContext, } } ================================================ FILE: src/services/getGlobalHooksForRouter.ts ================================================ import { Hooks } from '@/models/hooks' import { isRouterPlugin, RouterPlugin } from '@/types/routerPlugin' export function getGlobalHooksForRouter(plugins: RouterPlugin[] = []): Hooks { const hooks = new Hooks() plugins.forEach((plugin) => { if (!isRouterPlugin(plugin)) { return } plugin.hooks.onBeforeRouteEnter.forEach((hook) => hooks.onBeforeRouteEnter.add(hook)) plugin.hooks.onBeforeRouteUpdate.forEach((hook) => hooks.onBeforeRouteUpdate.add(hook)) plugin.hooks.onBeforeRouteLeave.forEach((hook) => hooks.onBeforeRouteLeave.add(hook)) plugin.hooks.onAfterRouteEnter.forEach((hook) => hooks.onAfterRouteEnter.add(hook)) plugin.hooks.onAfterRouteUpdate.forEach((hook) => hooks.onAfterRouteUpdate.add(hook)) plugin.hooks.onAfterRouteLeave.forEach((hook) => hooks.onAfterRouteLeave.add(hook)) }) return hooks } ================================================ FILE: src/services/getGlobalRouteHooks.ts ================================================ import { ResolvedRoute } from '@/types/resolved' import { isRouteEnter, isRouteLeave, isRouteUpdate } from './hooks' import { Hooks } from '@/models/hooks' export function getGlobalBeforeHooks(to: ResolvedRoute, from: ResolvedRoute | null, globalHooks: Hooks): Hooks { const hooks = new Hooks() to.matches.forEach((_route, depth) => { if (isRouteEnter(to, from, depth)) { globalHooks.onBeforeRouteEnter.forEach((hook) => hooks.onBeforeRouteEnter.add(hook)) } if (isRouteUpdate(to, from, depth)) { globalHooks.onBeforeRouteUpdate.forEach((hook) => hooks.onBeforeRouteUpdate.add(hook)) } }) from?.matches.forEach((_route, depth) => { if (isRouteLeave(to, from, depth)) { globalHooks.onBeforeRouteLeave.forEach((hook) => hooks.onBeforeRouteLeave.add(hook)) } }) return hooks } export function getGlobalAfterHooks(to: ResolvedRoute, from: ResolvedRoute | null, globalHooks: Hooks): Hooks { const hooks = new Hooks() to.matches.forEach((_route, depth) => { if (isRouteEnter(to, from, depth)) { globalHooks.onAfterRouteEnter.forEach((hook) => hooks.onAfterRouteEnter.add(hook)) } if (isRouteUpdate(to, from, depth)) { globalHooks.onAfterRouteUpdate.forEach((hook) => hooks.onAfterRouteUpdate.add(hook)) } }) from?.matches.forEach((_route, depth) => { if (isRouteLeave(to, from, depth)) { globalHooks.onAfterRouteLeave.forEach((hook) => hooks.onAfterRouteLeave.add(hook)) } }) return hooks } ================================================ FILE: src/services/getInitialUrl.browser.spec.ts ================================================ import { expect, test, vi } from 'vitest' import { getInitialUrl } from '@/services/getInitialUrl' import { random } from '@/utilities/testHelpers' test('given value for initial route, returns value', () => { const initialRoute = random.number().toString() const response = getInitialUrl(initialRoute) expect(response).toBe(initialRoute) }) test('defaults to window.location without protocol or host', () => { const initialRoute = 'https://localhost:5173/home?with=search#foo' vi.stubGlobal('location', initialRoute) const response = getInitialUrl() expect(response).toBe(initialRoute) }) ================================================ FILE: src/services/getInitialUrl.spec.ts ================================================ import { expect, test } from 'vitest' import { getInitialUrl } from '@/services/getInitialUrl' test('throws error if initial route is not set', () => { expect(() => getInitialUrl()).toThrowError('initialUrl must be set if window.location is unavailable') }) ================================================ FILE: src/services/getInitialUrl.ts ================================================ import { InitialRouteMissingError } from '@/errors/initialRouteMissingError' import { isBrowser } from '@/utilities/isBrowser' export function getInitialUrl(initialUrl?: string): string { if (initialUrl) { return initialUrl } if (isBrowser()) { return window.location.toString() } throw new InitialRouteMissingError() } ================================================ FILE: src/services/getMatchesForUrl.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { createRoute } from '@/services/createRoute' import { getMatchForUrl } from '@/services/getMatchesForUrl' import { component } from '@/utilities/testHelpers' test('given path WITHOUT params, returns match', () => { const parent = createRoute({ name: 'parent', path: '/parent', }) const child = createRoute({ parent: parent, name: 'parent.child', path: '/child', }) const grandchild = createRoute({ parent: child, name: 'parent.child.grandchild', path: '/grandchild', component, }) const routes = [parent, child, grandchild] const match = getMatchForUrl(routes, '/parent/child/grandchild') expect(match).toBeDefined() expect(match?.name).toBe('parent.child.grandchild') expect(match?.matched.name).toBe('parent.child.grandchild') }) test('given path to unnamed parent, without option to get to leaf, returns no matches', () => { const unnamedParent = createRoute({ path: '/unnamed', }) const unnamedChild = createRoute({ parent: unnamedParent, path: '/unnamed-child/[child-id]', }) const namedGrandchild = createRoute({ parent: unnamedChild, path: '/named-grandchild', component, }) const routes = [unnamedParent, unnamedChild, namedGrandchild] const match = getMatchForUrl(routes, '/unnamed') expect(match).toBeUndefined() }) test('given path to unnamed parent, with option to get to leaf, returns available leaf', () => { const unnamedParent = createRoute({ path: '/unnamed', }) const unnamedChildRoot = createRoute({ parent: unnamedParent, name: 'child-root', component, }) const routes = [unnamedChildRoot, unnamedParent] const match = getMatchForUrl(routes, '/unnamed') expect(match).toBeDefined() expect(match?.name).toBe('child-root') }) test('given route with simple string param WITHOUT value present, returns no matches', () => { const route = createRoute({ name: 'simple-params', path: '/simple/[simple]', component, }) const response = getMatchForUrl([route], '/simple/') expect(response).toBeUndefined() }) test('given route with simple string query param WITHOUT value present, returns no matches', () => { const route = createRoute({ name: 'simple-params', path: '/missing', query: 'simple=[simple]', component, }) const response = getMatchForUrl([route], '/missing?without=params') expect(response).toBeUndefined() }) test('given route with equal matches, returns first match', () => { const firstRoute = createRoute({ name: 'first-route', path: '/', component, }) const secondRoute = createRoute({ parent: firstRoute, name: 'second-route', component, }) const thirdRoute = createRoute({ name: 'third-route', path: '/', component, }) const match = getMatchForUrl([ firstRoute, secondRoute, thirdRoute, ], '/') expect(match).toBeDefined() expect(match?.name).toBe('first-route') }) test('given url with query params that include params and extra values, retains extra query params', () => { const route = createRoute({ name: 'query-params', path: '/', query: 'foo=[param]', component, }) const match = getMatchForUrl([route], '/?extra=42&foo=1') expect(match).toBeDefined() expect(match?.query.toString()).toBe('foo=1&extra=42') }) describe('trailing slashes', () => { test('given route without trailing slash, does not match url with trailing slash', () => { const route = createRoute({ name: 'no-trailing-slash', path: '/parent/child', component, }) const match = getMatchForUrl([route], '/parent/child/') expect(match).toBeUndefined() }) test('given route with trailing slash, matches url without trailing slash', () => { const route = createRoute({ name: 'with-trailing-slash', path: '/parent/child/', component, }) const match = getMatchForUrl([route], '/parent/child') expect(match).toBeDefined() expect(match?.name).toBe('with-trailing-slash') }) }) ================================================ FILE: src/services/getMatchesForUrl.ts ================================================ import { createResolvedRoute } from '@/services/createResolvedRoute' import { parseUrl } from '@/services/urlParser' import { filterQueryParams } from '@/services/queryParamFilter' import { ResolvedRoute } from '@/types/resolved' import { Routes } from '@/types/route' import { ParseUrlOptions } from '@/types/url' import { isNamedRoute } from '@/utilities/isNamedRoute' type MatchOptions = { state?: Partial } & ParseUrlOptions export function getMatchForUrl(routes: Routes, url: string, options: MatchOptions = {}): ResolvedRoute | undefined { const { query, hash } = parseUrl(url) for (const route of routes) { if (!isNamedRoute(route)) { continue } const { success, params } = route.tryParse(url, options) if (success) { const updatedUrl = route.stringify(params) const { query: updatedQuery } = parseUrl(updatedUrl) const queryWithoutParams = filterQueryParams(query, updatedQuery) return createResolvedRoute(route, params, { ...options, query: queryWithoutParams, hash }) } } } ================================================ FILE: src/services/getParamsForString.ts ================================================ import { getParam } from '@/services/params' import { UrlParam, UrlParams } from '@/services/withParams' import { isParamWithDefault } from '@/services/withDefault' import { getParamName, isGreedyParamSyntax, isOptionalParamSyntax, paramRegex } from '@/services/routeRegex' import { Param } from '@/types/paramTypes' import { stringHasValue } from '@/utilities/guards' import { checkDuplicateParams } from '@/utilities/checkDuplicateParams' export function getParamsForString(string: string = '', params: Record = {}): UrlParams { if (!stringHasValue(string)) { return {} } const matches = Array.from(string.matchAll(new RegExp(paramRegex, 'g'))) return matches.reduce>((value, [match, key]) => { const paramName = getParamName(match) if (!paramName) { return value } const param = getParam(params, paramName) const isOptional = isOptionalParamSyntax(match) || isParamWithDefault(param) const isGreedy = isGreedyParamSyntax(match) checkDuplicateParams([paramName], value) value[key] = { param, isOptional, isGreedy } return value }, {}) } ================================================ FILE: src/services/getRejectionHooks.ts ================================================ import { Hooks } from '@/models/hooks' import { getHooks } from '@/types/hooks' import { Rejection } from '@/types/rejection' export function getRejectionHooksFromRejection(rejection: Rejection): Hooks { const hooks = new Hooks() getHooks(rejection).forEach((store) => { store.onRejection.forEach((hook) => hooks.onRejection.add(hook)) }) return hooks } ================================================ FILE: src/services/getRouteHooks.spec.ts ================================================ import { expect, test, vi } from 'vitest' import { getBeforeHooksFromRoutes } from '@/services/getRouteHooks' import { createRoute } from './createRoute' import { createResolvedRoute } from './createResolvedRoute' import { getHooks } from '@/types/hooks' test('given two ResolvedRoutes returns before timing hooks in correct order', () => { const parent = createRoute({ name: 'parentA', }) parent.onBeforeRouteUpdate(vi.fn()) const childA = createRoute({ name: 'childA', parent: parent, }) childA.onBeforeRouteEnter(vi.fn()) childA.onBeforeRouteUpdate(vi.fn()) childA.onBeforeRouteLeave(vi.fn()) const grandchildA = createRoute({ name: 'grandchildA', parent: childA, }) grandchildA.onBeforeRouteEnter(vi.fn()) grandchildA.onBeforeRouteUpdate(vi.fn()) grandchildA.onBeforeRouteLeave(vi.fn()) const childB = createRoute({ name: 'childB', parent: parent, }) childB.onBeforeRouteEnter(vi.fn()) childB.onBeforeRouteUpdate(vi.fn()) childB.onBeforeRouteLeave(vi.fn()) const grandchildB = createRoute({ name: 'grandchildB', parent: childB, }) grandchildB.onBeforeRouteEnter(vi.fn()) grandchildB.onBeforeRouteUpdate(vi.fn()) grandchildB.onBeforeRouteLeave(vi.fn()) const to = createResolvedRoute(grandchildA, {}) const from = createResolvedRoute(grandchildB, {}) const hooks = getBeforeHooksFromRoutes(to, from) expect(Array.from(hooks.onBeforeRouteEnter)).toMatchObject([...Array.from(getHooks(childA).at(-1)?.onBeforeRouteEnter ?? []), ...Array.from(getHooks(grandchildA).at(-1)?.onBeforeRouteEnter ?? [])]) expect(Array.from(hooks.onBeforeRouteUpdate)).toMatchObject([...Array.from(getHooks(parent).at(-1)?.onBeforeRouteUpdate ?? [])]) expect(Array.from(hooks.onBeforeRouteLeave)).toMatchObject([...Array.from(getHooks(childB).at(-1)?.onBeforeRouteLeave ?? []), ...Array.from(getHooks(grandchildB).at(-1)?.onBeforeRouteLeave ?? [])]) }) ================================================ FILE: src/services/getRouteHooks.ts ================================================ import { ResolvedRoute } from '@/types/resolved' import { isRouteEnter, isRouteLeave, isRouteUpdate } from '@/services/hooks' import { Hooks } from '@/models/hooks' import { getHooks } from '@/types/hooks' export function getBeforeHooksFromRoutes(to: ResolvedRoute, from: ResolvedRoute | null): Hooks { const hooks = new Hooks() getHooks(to).forEach((store, depth) => { store.redirects.forEach((hook) => hooks.redirects.add(hook)) if (isRouteEnter(to, from, depth)) { return store.onBeforeRouteEnter.forEach((hook) => hooks.onBeforeRouteEnter.add(hook)) } if (isRouteUpdate(to, from, depth)) { return store.onBeforeRouteUpdate.forEach((hook) => hooks.onBeforeRouteUpdate.add(hook)) } }) getHooks(from).forEach((store, depth) => { if (isRouteLeave(to, from, depth)) { return store.onBeforeRouteLeave.forEach((hook) => hooks.onBeforeRouteLeave.add(hook)) } }) return hooks } export function getAfterHooksFromRoutes(to: ResolvedRoute, from: ResolvedRoute | null): Hooks { const hooks = new Hooks() getHooks(to).forEach((store, depth) => { if (isRouteEnter(to, from, depth)) { return store.onAfterRouteEnter.forEach((hook) => hooks.onAfterRouteEnter.add(hook)) } if (isRouteUpdate(to, from, depth)) { return store.onAfterRouteUpdate.forEach((hook) => hooks.onAfterRouteUpdate.add(hook)) } }) getHooks(from).forEach((store, depth) => { if (isRouteLeave(to, from, depth)) { return store.onAfterRouteLeave.forEach((hook) => hooks.onAfterRouteLeave.add(hook)) } }) return hooks } ================================================ FILE: src/services/getRoutesForRouter.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { createRoute } from '@/services/createRoute' import { getRoutesForRouter } from '@/services/getRoutesForRouter' import { component } from '@/utilities' import { createRouterPlugin } from '@/services/createRouterPlugin' test('given routes without names, removes routes from response', () => { const foo = createRoute({ name: 'foo' }) const { routes } = getRoutesForRouter([ foo, createRoute({ component, name: '' }), createRoute({ component, name: undefined }), createRoute({ component }), ]) expect(routes).toMatchObject([foo]) }) test('given named routes inside plugins, includes them in the response', () => { const pluginFoo = createRoute({ name: 'plugin-foo' }) const plugins = [ createRouterPlugin({ routes: [ pluginFoo, createRoute({ name: '' }), createRoute({ name: undefined }), createRoute({ component }), ], }), ] const { routes } = getRoutesForRouter([], plugins) expect(routes).toMatchObject([pluginFoo]) }) test('given named routes inside route context, includes them in the response', () => { const relatedRoute = createRoute({ name: 'related' }) const fooRoute = createRoute({ name: 'foo', context: [relatedRoute] }) const barRoute = createRoute({ name: 'bar', context: [relatedRoute] }) const zooRoute = createRoute({ name: 'zoo', context: [relatedRoute] }) const plugins = [ createRouterPlugin({ routes: [ fooRoute, barRoute, zooRoute, ], }), ] const { routes } = getRoutesForRouter([], plugins) expect(routes).toMatchObject([ fooRoute, relatedRoute, barRoute, zooRoute, ]) }) test('given named routes inside route context of plugin routes, includes them in the response', () => { const relatedRoute = createRoute({ name: 'related' }) const fooRoute = createRoute({ name: 'foo', context: [relatedRoute] }) const barRoute = createRoute({ name: 'bar', context: [relatedRoute] }) const zooRoute = createRoute({ name: 'zoo', context: [relatedRoute] }) const { routes } = getRoutesForRouter([ fooRoute, barRoute, zooRoute, ]) expect(routes).toMatchObject([ fooRoute, relatedRoute, barRoute, zooRoute, ]) }) test('return routes sorted by depth', () => { const routeA = createRoute({ name: 'a' }) const routeB = createRoute({ name: 'b', parent: routeA }) const routeC = createRoute({ name: 'c', parent: routeB }) const routeD = createRoute({ name: 'd', parent: routeA }) const routeE = createRoute({ name: 'e' }) const { routes } = getRoutesForRouter([routeA, routeB, routeC, routeD, routeE]) expect(routes.map((route) => route.name)).toMatchObject([ 'c', 'b', 'd', 'a', 'e', ]) }) describe('getRouteByName', () => { test('circular context is ignored', () => { const routeA = createRoute({ name: 'a' }) const routeB = createRoute({ name: 'b', context: [routeA] }) // @ts-expect-error - you cannot actually do this routeA.context.push(routeB) routeB.context.push(routeA) expect(() => getRoutesForRouter([routeA, routeB])).not.toThrow() }) test('returns the route by name', () => { const route = createRoute({ name: 'foo' }) const { getRouteByName } = getRoutesForRouter([route]) expect(getRouteByName('foo')).toBe(route) }) test('getRouteByName returns undefined if the route is not found', () => { const { getRouteByName } = getRoutesForRouter([]) expect(getRouteByName('foo')).toBeUndefined() }) }) ================================================ FILE: src/services/getRoutesForRouter.ts ================================================ import { isRoute, Route, RouteInternal, Routes } from '@/types/route' import { RouterPlugin } from '@/types/routerPlugin' import { DuplicateNamesError } from '@/errors/duplicateNamesError' import { isNamedRoute } from '@/utilities/isNamedRoute' import { insertBaseRoute } from '@/services/insertBaseRoute' import { BUILT_IN_REJECTION_TYPES, BuiltInRejectionType, isRejection, Rejection, RejectionInternal, Rejections } from '@/types/rejection' import { RouterOptions } from '@/types/router' import { createRejection } from '@/services/createRejection' /** * Takes in routes and plugins and returns a list of routes with the base route inserted if provided. * Also checks for duplicate names in the routes. * * @throws {DuplicateNamesError} If there are duplicate names in the routes. */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function getRoutesForRouter(routes: Routes | Routes[], plugins: RouterPlugin[] = [], options: RouterOptions = {}) { const routerRoutes = new Map() const routerRejections = new Map() const allRoutes = [ ...routes, ...plugins.map((plugin) => plugin.routes), ] const allRejections = [ ...BUILT_IN_REJECTION_TYPES.map((type) => createRejection({ type })), ...options.rejections ?? [], ...plugins.map((plugin) => plugin.rejections), ] function addRoute(route: Route): void { if (!isRoute(route) || !isNamedRoute(route)) { return } const existingRouteByName = routerRoutes.get(route.name) if (existingRouteByName && existingRouteByName.id !== route.id) { throw new DuplicateNamesError(route.name) } if (existingRouteByName && existingRouteByName.id === route.id) { return } routerRoutes.set(route.name, insertBaseRoute(route, options.base)) for (const context of route.context) { if (isRoute(context)) { addRoute(context) } if (isRejection(context)) { addRejection(context) } } } function addRejection(rejection: Rejection): void { if (!isRejection(rejection)) { return } routerRejections.set(rejection.type, rejection) } function addRoutes(routes: Routes): void { for (const route of routes) { addRoute(route) } } function addRejections(rejections: Rejections): void { for (const rejection of rejections) { addRejection(rejection) } } for (const route of allRoutes) { if (isRoutes(route)) { addRoutes(route) continue } addRoute(route) } for (const rejection of allRejections) { if (isRejections(rejection)) { addRejections(rejection) continue } addRejection(rejection) } function getRouteByName(type: string): Route | undefined { return routerRoutes.get(type) } function getRejectionByType(type: BuiltInRejectionType): (Rejection & RejectionInternal) function getRejectionByType(type: string): (Rejection & RejectionInternal) | undefined function getRejectionByType(type: string): (Rejection & RejectionInternal) | undefined { return routerRejections.get(type) } return { routes: Array.from(routerRoutes.values()).sort(sortByDepthDescending), rejections: Array.from(routerRejections.values()), getRouteByName, getRejectionByType, } } function isRoutes(routes: Routes | Route): routes is Routes { return Array.isArray(routes) } function isRejections(rejections: Rejections | Rejection): rejections is Rejections { return Array.isArray(rejections) } function sortByDepthDescending(aRoute: Route & RouteInternal, bRoute: Route & RouteInternal): number { return bRoute.depth - aRoute.depth } ================================================ FILE: src/services/history.browser.spec.ts ================================================ import { BrowserHistory, createBrowserHistory, createHashHistory, createMemoryHistory, HashHistory, MemoryHistory } from '@/services/history' import { afterEach, beforeEach, describe, expect, test } from 'vitest' describe('a browser history', () => { let history: BrowserHistory beforeEach(() => { // @ts-expect-error - copied test from history window.history.replaceState(null, null, '/') history = createBrowserHistory() }) test('knows how to create hrefs from location objects', () => { const href = history.createHref({ pathname: '/the/path', search: '?the=query', hash: '#the-hash', }) expect(href).toEqual('/the/path?the=query#the-hash') }) test('knows how to create hrefs from strings', () => { const href = history.createHref('/the/path?the=query#the-hash') expect(href).toEqual('/the/path?the=query#the-hash') }) test('does not encode the generated path', () => { const encodedHref = history.createHref({ pathname: '/%23abc', }) expect(encodedHref).toEqual('/%23abc') const unencodedHref = history.createHref({ pathname: '/#abc', }) expect(unencodedHref).toEqual('/#abc') }) }) describe('a hash history on a page with a tag', () => { let history: HashHistory, base: HTMLBaseElement beforeEach(() => { if (window.location.hash !== '#/') { window.location.hash = '/' } base = document.createElement('base') base.setAttribute('href', '/prefix') document.head.appendChild(base) history = createHashHistory() }) afterEach(() => { document.head.removeChild(base) }) test('knows how to create hrefs', () => { const hashIndex = window.location.href.indexOf('#') const upToHash = hashIndex === -1 ? window.location.href : window.location.href.slice(0, hashIndex) const href = history.createHref({ pathname: '/the/path', search: '?the=query', hash: '#the-hash', }) expect(href).toEqual(upToHash + '#/the/path?the=query#the-hash') }) }) describe('a hash history', () => { let history: HashHistory beforeEach(() => { window.history.replaceState(null, null as any as string, '#/') // FIXME: type history = createHashHistory() }) test('knows how to create hrefs from location objects', () => { const href = history.createHref({ pathname: '/the/path', search: '?the=query', hash: '#the-hash', }) expect(href).toEqual('#/the/path?the=query#the-hash') }) test('knows how to create hrefs from strings', () => { const href = history.createHref('/the/path?the=query#the-hash') expect(href).toEqual('#/the/path?the=query#the-hash') }) test('does not encode the generated path', () => { const encodedHref = history.createHref({ pathname: '/%23abc', }) expect(encodedHref).toEqual('#/%23abc') const unencodedHref = history.createHref({ pathname: '/#abc', }) expect(unencodedHref).toEqual('#/#abc') }) }) describe('a memory history', () => { let history: MemoryHistory beforeEach(() => { history = createMemoryHistory() }) test('has an index property', () => { expect(typeof history.index).toBe('number') }) test('knows how to create hrefs', () => { const href = history.createHref({ pathname: '/the/path', search: '?the=query', hash: '#the-hash', }) expect(href).toEqual('/the/path?the=query#the-hash') }) test('knows how to create hrefs from strings', () => { const href = history.createHref('/the/path?the=query#the-hash') expect(href).toEqual('/the/path?the=query#the-hash') }) test('does not encode the generated path', () => { const encodedHref = history.createHref({ pathname: '/%23abc', }) expect(encodedHref).toEqual('/%23abc') const unencodedHref = history.createHref({ pathname: '/#abc', }) expect(unencodedHref).toEqual('/#abc') }) }) ================================================ FILE: src/services/history.ts ================================================ export type Action = 'POP' | 'PUSH' | 'REPLACE' export type Pathname = string export type Search = string export type Hash = string export type Key = string export interface Path { pathname: Pathname, search: Search, hash: Hash, } export interface Location extends Path { state: unknown, key: Key, } export interface Update { action: Action, location: Location, } export type Listener = (update: Update) => void export interface Transition extends Update { retry: () => void, } export type Blocker = (tx: Transition) => void export type To = string | Partial export interface History { readonly action: Action, readonly location: Location, createHref: (to: To) => string, push: (to: To, state?: unknown) => void, replace: (to: To, state?: unknown) => void, go: (delta: number) => void, back: () => void, forward: () => void, listen: (listener: Listener) => () => void, block: (blocker: Blocker) => () => void, } export interface BrowserHistory extends History {} export interface HashHistory extends History {} export interface MemoryHistory extends History { readonly index: number, } export type BrowserHistoryOptions = { window?: Window, } export type HashHistoryOptions = { window?: Window, } export type InitialEntry = string | Partial export type MemoryHistoryOptions = { initialEntries?: InitialEntry[], initialIndex?: number, } // UTILS function createEvents(): { readonly length: number, push: (fn: (arg: T) => void) => () => void, call: (arg: T) => void, } { let handlers: ((arg: T) => void)[] = [] return { get length() { return handlers.length }, push(fn: (arg: T) => void): () => void { handlers.push(fn) return () => { handlers = handlers.filter((handler) => handler !== fn) } }, call(arg: T): void { handlers.forEach((fn) => fn(arg)) }, } } function createKey(): string { return Math.random() .toString(36) .slice(2, 10) } function clamp(n: number, lowerBound: number, upperBound: number): number { return Math.min(Math.max(n, lowerBound), upperBound) } function promptBeforeUnload(event: BeforeUnloadEvent): void { event.preventDefault() // Chrome (and legacy IE) requires returnValue to be set // eslint-disable-next-line @typescript-eslint/no-deprecated event.returnValue = '' } export function createPath({ pathname = '/', search = '', hash = '' }: Partial): string { let result = pathname if (search && search !== '?') result += search.startsWith('?') ? search : '?' + search if (hash && hash !== '#') result += hash.startsWith('#') ? hash : '#' + hash return result } export function parsePath(path: string): Partial { const parsedPath: Partial = {} if (path) { const hashIndex = path.indexOf('#') if (hashIndex >= 0) { parsedPath.hash = path.slice(hashIndex) path = path.slice(0, hashIndex) } const searchIndex = path.indexOf('?') if (searchIndex >= 0) { parsedPath.search = path.slice(searchIndex) path = path.slice(0, searchIndex) } if (path) { parsedPath.pathname = path } } return parsedPath } function readOnly(obj: T): Readonly { return Object.freeze(obj) } // BROWSER HISTORY const BeforeUnloadEventType = 'beforeunload' const PopStateEventType = 'popstate' const HashChangeEventType = 'hashchange' export function createBrowserHistory(options: BrowserHistoryOptions = {}): BrowserHistory { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { window: win = document.defaultView! } = options const globalHistory = win.history function getIndexAndLocation(): [number | null, Location] { const { pathname, search, hash } = win.location const state = globalHistory.state ?? {} return [ state.idx, readOnly({ pathname, search, hash, state: state.usr ?? null, key: state.key ?? 'default', }), ] } let blockedPopTx: Transition | null = null function handlePop(): void { if (blockedPopTx) { blockers.call(blockedPopTx) blockedPopTx = null } else { const nextAction = 'POP' const [nextIndex, nextLocation] = getIndexAndLocation() if (blockers.length) { if (nextIndex != null) { const delta = index ?? 0 - nextIndex if (delta) { blockedPopTx = { action: nextAction, location: nextLocation, retry() { go(delta * -1) }, } go(delta) } } } else { applyTx(nextAction) } } } win.addEventListener(PopStateEventType, handlePop) let action: Action = 'POP' let [index, location] = getIndexAndLocation() const listeners = createEvents() const blockers = createEvents() if (index == null) { index = 0 globalHistory.replaceState({ ...globalHistory.state, idx: index }, '', createHref(location)) } function createHref(to: To): string { return typeof to === 'string' ? to : createPath(to) } function getNextLocation(to: To, state: unknown = null): Location { return readOnly({ pathname: location.pathname, hash: '', search: '', ...typeof to === 'string' ? parsePath(to) : to, state, key: createKey(), }) } function getHistoryStateAndUrl(nextLocation: Location, idx: number): [Record, string] { return [ { usr: nextLocation.state, key: nextLocation.key, idx }, createHref(nextLocation), ] } function allowTx(txAction: Action, txLocation: Location, retry: () => void): boolean { if (blockers.length) { blockers.call({ action: txAction, location: txLocation, retry }) return false } return true } function applyTx(nextAction: Action): void { action = nextAction ;[index, location] = getIndexAndLocation() listeners.call({ action, location }) } function push(to: To, state?: unknown): void { const nextAction: Action = 'PUSH' const nextLocation = getNextLocation(to, state) function retry(): void { push(to, state) } if (allowTx(nextAction, nextLocation, retry)) { const [historyState, url] = getHistoryStateAndUrl(nextLocation, (index ?? 0) + 1) try { globalHistory.pushState(historyState, '', url) } catch { win.location.assign(url) } applyTx(nextAction) } } function replace(to: To, state?: unknown): void { const nextAction: Action = 'REPLACE' const nextLocation = getNextLocation(to, state) function retry(): void { replace(to, state) } if (allowTx(nextAction, nextLocation, retry)) { const [historyState, url] = getHistoryStateAndUrl(nextLocation, index ?? 0) globalHistory.replaceState(historyState, '', url) applyTx(nextAction) } } function go(delta: number): void { globalHistory.go(delta) } const history: BrowserHistory = { get action() { return action }, get location() { return location }, createHref, push, replace, go, back() { go(-1) }, forward() { go(1) }, listen(listener: Listener) { return listeners.push(listener) }, block(blocker: Blocker) { const unblock = blockers.push(blocker) if (blockers.length === 1) { win.addEventListener(BeforeUnloadEventType, promptBeforeUnload) } return () => { unblock() if (!blockers.length) { win.removeEventListener(BeforeUnloadEventType, promptBeforeUnload) } } }, } return history } // HASH HISTORY export function createHashHistory(options: HashHistoryOptions = {}): HashHistory { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { window: win = document.defaultView! } = options const globalHistory = win.history function getIndexAndLocation(): [number | null, Location] { const { pathname = '/', search = '', hash = '' } = parsePath(win.location.hash.slice(1)) const state = globalHistory.state ?? {} return [ state.idx, readOnly({ pathname, search, hash, state: state.usr ?? null, key: state.key ?? 'default', }), ] } let blockedPopTx: Transition | null = null function handlePop(): void { if (blockedPopTx) { blockers.call(blockedPopTx) blockedPopTx = null } else { const nextAction: Action = 'POP' const [nextIndex, nextLocation] = getIndexAndLocation() if (blockers.length) { if (nextIndex != null) { const delta = (index ?? 0) - nextIndex if (delta) { blockedPopTx = { action: nextAction, location: nextLocation, retry() { go(delta * -1) }, } go(delta) } } } else { applyTx(nextAction) } } } win.addEventListener(PopStateEventType, handlePop) win.addEventListener(HashChangeEventType, () => { const [, nextLocation] = getIndexAndLocation() if (createPath(nextLocation) !== createPath(location)) { handlePop() } }) let action: Action = 'POP' let [index, location] = getIndexAndLocation() const listeners = createEvents() const blockers = createEvents() if (index == null) { index = 0 globalHistory.replaceState({ ...globalHistory.state, idx: index }, '') } function getBaseHref(): string { const base = document.querySelector('base') let href = '' if (base?.getAttribute('href')) { const url = win.location.href const hashIndex = url.indexOf('#') href = hashIndex === -1 ? url : url.slice(0, hashIndex) } return href } function createHref(to: To): string { return getBaseHref() + '#' + (typeof to === 'string' ? to : createPath(to)) } function getNextLocation(to: To, state: unknown = null): Location { return readOnly({ pathname: location.pathname, hash: '', search: '', ...typeof to === 'string' ? parsePath(to) : to, state, key: createKey(), }) } function getHistoryStateAndUrl(nextLocation: Location, idx: number): [Record, string] { return [ { usr: nextLocation.state, key: nextLocation.key, idx }, createHref(nextLocation), ] } function allowTx(txAction: Action, txLocation: Location, retry: () => void): boolean { if (blockers.length) { blockers.call({ action: txAction, location: txLocation, retry }) return false } return true } function applyTx(nextAction: Action): void { action = nextAction ;[index, location] = getIndexAndLocation() listeners.call({ action, location }) } function push(to: To, state?: unknown): void { const nextAction: Action = 'PUSH' const nextLocation = getNextLocation(to, state) function retry(): void { push(to, state) } if (allowTx(nextAction, nextLocation, retry)) { const [historyState, url] = getHistoryStateAndUrl(nextLocation, (index ?? 0) + 1) try { globalHistory.pushState(historyState, '', url) } catch { win.location.assign(url) } applyTx(nextAction) } } function replace(to: To, state?: unknown): void { const nextAction: Action = 'REPLACE' const nextLocation = getNextLocation(to, state) function retry(): void { replace(to, state) } if (allowTx(nextAction, nextLocation, retry)) { const [historyState, url] = getHistoryStateAndUrl(nextLocation, index ?? 0) globalHistory.replaceState(historyState, '', url) applyTx(nextAction) } } function go(delta: number): void { globalHistory.go(delta) } const history: HashHistory = { get action() { return action }, get location() { return location }, createHref, push, replace, go, back() { go(-1) }, forward() { go(1) }, listen(listener: Listener) { return listeners.push(listener) }, block(blocker: Blocker) { const unblock = blockers.push(blocker) if (blockers.length === 1) { win.addEventListener(BeforeUnloadEventType, promptBeforeUnload) } return () => { unblock() if (!blockers.length) { win.removeEventListener(BeforeUnloadEventType, promptBeforeUnload) } } }, } return history } // MEMORY HISTORY export function createMemoryHistory(options: MemoryHistoryOptions = {}): MemoryHistory { const { initialEntries = ['/'], initialIndex } = options const entries: Location[] = initialEntries.map((entry) => readOnly({ pathname: '/', search: '', hash: '', state: null, key: createKey(), ...typeof entry === 'string' ? parsePath(entry) : entry, }), ) let index = clamp(initialIndex ?? entries.length - 1, 0, entries.length - 1) let action: Action = 'POP' let location = entries[index] const listeners = createEvents() const blockers = createEvents() function createHref(to: To): string { return typeof to === 'string' ? to : createPath(to) } function getNextLocation(to: To, state: unknown = null): Location { return readOnly({ pathname: location.pathname, search: '', hash: '', ...typeof to === 'string' ? parsePath(to) : to, state, key: createKey(), }) } function allowTx(txAction: Action, txLocation: Location, retry: () => void): boolean { if (blockers.length) { blockers.call({ action: txAction, location: txLocation, retry }) return false } return true } function applyTx(nextAction: Action, nextLocation: Location): void { action = nextAction location = nextLocation listeners.call({ action, location }) } function push(to: To, state?: unknown): void { const nextAction: Action = 'PUSH' const nextLocation = getNextLocation(to, state) function retry(): void { push(to, state) } if (allowTx(nextAction, nextLocation, retry)) { index += 1 entries.splice(index, entries.length, nextLocation) applyTx(nextAction, nextLocation) } } function replace(to: To, state?: unknown): void { const nextAction: Action = 'REPLACE' const nextLocation = getNextLocation(to, state) function retry(): void { replace(to, state) } if (allowTx(nextAction, nextLocation, retry)) { entries[index] = nextLocation applyTx(nextAction, nextLocation) } } function go(delta: number): void { const nextIndex = clamp(index + delta, 0, entries.length - 1) const nextAction: Action = 'POP' const nextLocation = entries[nextIndex] function retry(): void { go(delta) } if (allowTx(nextAction, nextLocation, retry)) { index = nextIndex applyTx(nextAction, nextLocation) } } const history: MemoryHistory = { get index() { return index }, get action() { return action }, get location() { return location }, createHref, push, replace, go, back() { go(-1) }, forward() { go(1) }, listen(listener: Listener) { return listeners.push(listener) }, block(blocker: Blocker) { return blockers.push(blocker) }, } return history } ================================================ FILE: src/services/hooks.browser.spec.ts ================================================ import { expect, test, vi } from 'vitest' import { createRoute } from './createRoute' import { createRouter } from './createRouter' import { h } from 'vue' import { mount } from '@vue/test-utils' import { routes } from '@/utilities/testHelpers' import { createExternalRoute } from './createExternalRoute' import { createRejection } from './createRejection' import { onAfterRouteLeave, onAfterRouteUpdate, onBeforeRouteLeave, onBeforeRouteUpdate, RouterView } from '@/main' test('global hooks are called correctly', async () => { const router = createRouter(routes, { initialUrl: '/parentA/valueA' }) const onBeforeRouteEnter = vi.fn() const onBeforeRouteUpdate = vi.fn() const onBeforeRouteLeave = vi.fn() const onAfterRouteEnter = vi.fn() const onAfterRouteUpdate = vi.fn() const onAfterRouteLeave = vi.fn() const onError = vi.fn() const onRejection = vi.fn() router.onBeforeRouteEnter(onBeforeRouteEnter) router.onAfterRouteEnter(onAfterRouteEnter) router.onBeforeRouteUpdate(onBeforeRouteUpdate) router.onAfterRouteUpdate(onAfterRouteUpdate) router.onBeforeRouteLeave(onBeforeRouteLeave) router.onAfterRouteLeave(onAfterRouteLeave) router.onError(onError) router.onRejection(onRejection) await router.start() expect(onBeforeRouteEnter).toHaveBeenCalledTimes(1) expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(0) expect(onBeforeRouteLeave).toHaveBeenCalledTimes(0) expect(onAfterRouteLeave).toHaveBeenCalledTimes(0) expect(onAfterRouteUpdate).toHaveBeenCalledTimes(0) expect(onAfterRouteEnter).toHaveBeenCalledTimes(1) expect(onError).toHaveBeenCalledTimes(0) expect(onRejection).toHaveBeenCalledTimes(0) await router.push('parentA.childA', { paramA: 'valueA', paramB: 'valueB' }) expect(onBeforeRouteEnter).toHaveBeenCalledTimes(2) expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(1) expect(onBeforeRouteLeave).toHaveBeenCalledTimes(0) expect(onAfterRouteLeave).toHaveBeenCalledTimes(0) expect(onAfterRouteUpdate).toHaveBeenCalledTimes(1) expect(onAfterRouteEnter).toHaveBeenCalledTimes(2) expect(onError).toHaveBeenCalledTimes(0) expect(onRejection).toHaveBeenCalledTimes(0) await router.push('parentA.childB', { paramA: 'valueB', paramD: 'valueD' }) expect(onBeforeRouteEnter).toHaveBeenCalledTimes(3) expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(2) expect(onBeforeRouteLeave).toHaveBeenCalledTimes(1) expect(onAfterRouteLeave).toHaveBeenCalledTimes(1) expect(onAfterRouteUpdate).toHaveBeenCalledTimes(2) expect(onAfterRouteEnter).toHaveBeenCalledTimes(3) expect(onError).toHaveBeenCalledTimes(0) expect(onRejection).toHaveBeenCalledTimes(0) await router.push('parentB') expect(onBeforeRouteEnter).toHaveBeenCalledTimes(4) expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(2) expect(onBeforeRouteLeave).toHaveBeenCalledTimes(2) expect(onAfterRouteLeave).toHaveBeenCalledTimes(2) expect(onAfterRouteUpdate).toHaveBeenCalledTimes(2) expect(onAfterRouteEnter).toHaveBeenCalledTimes(4) expect(onError).toHaveBeenCalledTimes(0) expect(onRejection).toHaveBeenCalledTimes(0) router.reject('NotFound') expect(onBeforeRouteEnter).toHaveBeenCalledTimes(4) expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(2) expect(onBeforeRouteLeave).toHaveBeenCalledTimes(2) expect(onAfterRouteLeave).toHaveBeenCalledTimes(2) expect(onAfterRouteUpdate).toHaveBeenCalledTimes(2) expect(onAfterRouteEnter).toHaveBeenCalledTimes(4) expect(onError).toHaveBeenCalledTimes(0) expect(onRejection).toHaveBeenCalledTimes(1) }) test('route hooks are called correctly', async () => { const parentHooks = { beforeEnter: vi.fn(), beforeLeave: vi.fn(), beforeUpdate: vi.fn(), afterEnter: vi.fn(), afterLeave: vi.fn(), afterUpdate: vi.fn(), } const parentA = createRoute({ name: 'parentA', path: '/parentA', }) parentA.onBeforeRouteEnter(() => parentHooks.beforeEnter()) parentA.onBeforeRouteLeave(() => parentHooks.beforeLeave()) parentA.onBeforeRouteUpdate(() => parentHooks.beforeUpdate()) parentA.onAfterRouteEnter(() => parentHooks.afterEnter()) parentA.onAfterRouteLeave(() => parentHooks.afterLeave()) parentA.onAfterRouteUpdate(() => parentHooks.afterUpdate()) const parentB = createRoute({ name: 'parentB', path: '/parentB', }) const child = createRoute({ name: 'child', path: '/child/[param]', parent: parentA, }) const router = createRouter([parentA, parentB, child], { initialUrl: '/parentA', }) const root = { template: '', } mount(root, { global: { plugins: [router], }, }) await router.start() expect(parentHooks.beforeEnter).toHaveBeenCalledTimes(1) expect(parentHooks.beforeUpdate).toHaveBeenCalledTimes(0) expect(parentHooks.beforeLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterUpdate).toHaveBeenCalledTimes(0) expect(parentHooks.afterEnter).toHaveBeenCalledTimes(1) await router.push('child', { param: 'param2' }) expect(parentHooks.beforeEnter).toHaveBeenCalledTimes(1) expect(parentHooks.beforeUpdate).toHaveBeenCalledTimes(1) expect(parentHooks.beforeLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterUpdate).toHaveBeenCalledTimes(1) expect(parentHooks.afterEnter).toHaveBeenCalledTimes(1) await router.push('parentA') expect(parentHooks.beforeEnter).toHaveBeenCalledTimes(1) expect(parentHooks.beforeUpdate).toHaveBeenCalledTimes(2) expect(parentHooks.beforeLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterUpdate).toHaveBeenCalledTimes(2) expect(parentHooks.afterEnter).toHaveBeenCalledTimes(1) await router.push('parentB') expect(parentHooks.beforeEnter).toHaveBeenCalledTimes(1) expect(parentHooks.beforeUpdate).toHaveBeenCalledTimes(2) expect(parentHooks.beforeLeave).toHaveBeenCalledTimes(1) expect(parentHooks.afterLeave).toHaveBeenCalledTimes(1) expect(parentHooks.afterUpdate).toHaveBeenCalledTimes(2) expect(parentHooks.afterEnter).toHaveBeenCalledTimes(1) }) test('external route hooks are called correctly', async () => { const internalHooks = { beforeEnter: vi.fn(), beforeLeave: vi.fn(), beforeUpdate: vi.fn(), afterEnter: vi.fn(), afterLeave: vi.fn(), afterUpdate: vi.fn(), onRejection: vi.fn(), } const internal = createRoute({ name: 'internal', path: '/', }) internal.onBeforeRouteEnter(() => internalHooks.beforeEnter()) internal.onBeforeRouteLeave(() => internalHooks.beforeLeave()) internal.onBeforeRouteUpdate(() => internalHooks.beforeUpdate()) internal.onAfterRouteEnter(() => internalHooks.afterEnter()) internal.onAfterRouteLeave(() => internalHooks.afterLeave()) internal.onAfterRouteUpdate(() => internalHooks.afterUpdate()) const externalHooks = { beforeEnter: vi.fn(), } const external = createExternalRoute({ name: 'external', host: 'https://kitbag.dev', path: '/', }) external.onBeforeRouteEnter(() => externalHooks.beforeEnter()) const router = createRouter([internal, external], { initialUrl: '/', }) const root = { template: '', } mount(root, { global: { plugins: [router], }, }) await router.start() expect(internalHooks.beforeEnter).toHaveBeenCalledTimes(1) expect(internalHooks.beforeUpdate).toHaveBeenCalledTimes(0) expect(internalHooks.beforeLeave).toHaveBeenCalledTimes(0) expect(internalHooks.afterLeave).toHaveBeenCalledTimes(0) expect(internalHooks.afterUpdate).toHaveBeenCalledTimes(0) expect(internalHooks.afterEnter).toHaveBeenCalledTimes(1) expect(externalHooks.beforeEnter).toHaveBeenCalledTimes(0) expect(internalHooks.onRejection).toHaveBeenCalledTimes(0) await router.push('external') expect(internalHooks.beforeEnter).toHaveBeenCalledTimes(1) expect(internalHooks.beforeUpdate).toHaveBeenCalledTimes(0) expect(internalHooks.beforeLeave).toHaveBeenCalledTimes(1) expect(internalHooks.afterLeave).toHaveBeenCalledTimes(1) expect(internalHooks.afterUpdate).toHaveBeenCalledTimes(0) expect(internalHooks.afterEnter).toHaveBeenCalledTimes(1) expect(externalHooks.beforeEnter).toHaveBeenCalledTimes(1) expect(internalHooks.onRejection).toHaveBeenCalledTimes(0) }) test('rejection hooks are called correctly', async () => { const onRejection = vi.fn() const rejection = createRejection({ type: 'CustomRejection', }) rejection.onRejection((type, { to, from }) => onRejection(type, { to, from })) const router = createRouter(routes, { initialUrl: '/', rejections: [rejection] }) await router.start() expect(onRejection).toHaveBeenCalledTimes(0) router.reject('CustomRejection') expect(onRejection).toHaveBeenCalledTimes(1) expect(onRejection).toHaveBeenCalledWith('CustomRejection', expect.objectContaining({ to: null, from: null, })) }) test('component hooks are called correctly', async () => { const parentHooks = { beforeUpdate: vi.fn(), beforeLeave: vi.fn(), afterLeave: vi.fn(), afterUpdate: vi.fn(), } const parentA = createRoute({ name: 'parentA', path: '/parentA', component: { setup: () => { onBeforeRouteUpdate(() => parentHooks.beforeUpdate()) onBeforeRouteLeave(() => parentHooks.beforeLeave()) onAfterRouteLeave(() => parentHooks.afterLeave()) onAfterRouteUpdate(() => parentHooks.afterUpdate()) }, render: () => h(RouterView), }, }) const parentB = createRoute({ name: 'parentB', path: '/parentB', }) const child = createRoute({ name: 'child', path: '/child/[param]', parent: parentA, }) const router = createRouter([parentA, parentB, child], { initialUrl: '/parentA', }) const root = { template: '', } mount(root, { global: { plugins: [router], }, }) await router.start() expect(parentHooks.beforeUpdate).toHaveBeenCalledTimes(0) expect(parentHooks.beforeLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterUpdate).toHaveBeenCalledTimes(0) await router.push('child', { param: 'param2' }) expect(parentHooks.beforeUpdate).toHaveBeenCalledTimes(1) expect(parentHooks.beforeLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterUpdate).toHaveBeenCalledTimes(1) await router.push('parentA', {}) expect(parentHooks.beforeUpdate).toHaveBeenCalledTimes(2) expect(parentHooks.beforeLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterLeave).toHaveBeenCalledTimes(0) expect(parentHooks.afterUpdate).toHaveBeenCalledTimes(2) await router.push('parentB') expect(parentHooks.beforeUpdate).toHaveBeenCalledTimes(2) expect(parentHooks.beforeLeave).toHaveBeenCalledTimes(1) expect(parentHooks.afterLeave).toHaveBeenCalledTimes(1) expect(parentHooks.afterUpdate).toHaveBeenCalledTimes(2) }) ================================================ FILE: src/services/hooks.spec.ts ================================================ import { expect, test, vi } from 'vitest' import { createRouterHooks } from '@/services/createRouterHooks' import { BeforeEnterHook } from '@/types/hooks' import { ResolvedRoute } from '@/types/resolved' import { component } from '@/utilities/testHelpers' import { createRoute } from './createRoute' import { createResolvedRoute } from './createResolvedRoute' test('calls hook with correct routes', () => { const hook = vi.fn() const { runBeforeRouteHooks } = createRouterHooks() const toRoute = createRoute({ id: Math.random().toString(), name: 'routeA', component, }) toRoute.onBeforeRouteEnter(hook) const fromRoute = createRoute({ id: Math.random().toString(), name: 'routeB', component, }) const to = createResolvedRoute(toRoute, {}) const from = createResolvedRoute(fromRoute, {}) runBeforeRouteHooks({ to, from }) expect(hook).toHaveBeenCalledOnce() }) test.each<{ type: string, status: string, hook: BeforeEnterHook }>([ { type: 'reject', status: 'REJECT', hook: (_to, { reject }) => { reject('NotFound') }, }, { type: 'push', status: 'PUSH', hook: (_to, { push }) => push('/') }, { type: 'replace', status: 'PUSH', hook: (_to, { replace }) => replace('/') }, { type: 'update', status: 'PUSH', hook: (_to, { update }) => update('paramName', 'value') }, { type: 'abort', status: 'ABORT', hook: (_to, { abort }) => { abort() }, }, ])('Returns correct status when hook is called', async ({ status, hook }) => { const { runBeforeRouteHooks } = createRouterHooks() const toRoute = createRoute({ id: Math.random().toString(), name: 'routeA', component, }) toRoute.onBeforeRouteEnter(hook) const fromRoute = createRoute({ id: Math.random().toString(), name: 'routeB', component, }) fromRoute.onBeforeRouteEnter(hook) const to = createResolvedRoute(toRoute, {}) const from = createResolvedRoute(fromRoute, {}) const response = await runBeforeRouteHooks({ to, from }) expect(response.status).toBe(status) }) test('hook is called in order', async () => { const hookA = vi.fn() const hookB = vi.fn() const hookC = vi.fn() const { runBeforeRouteHooks } = createRouterHooks() const toRoute = createRoute({ id: Math.random().toString(), name: 'routeA', component, }) toRoute.onBeforeRouteEnter(hookA) toRoute.onBeforeRouteEnter(hookB) toRoute.onBeforeRouteEnter(hookC) const fromRoute = createRoute({ id: Math.random().toString(), name: 'routeB', component, }) const to = createResolvedRoute(toRoute, {}) const from = createResolvedRoute(fromRoute, {}) await runBeforeRouteHooks({ to, from }) const [orderA] = hookA.mock.invocationCallOrder const [orderB] = hookB.mock.invocationCallOrder const [orderC] = hookC.mock.invocationCallOrder expect(orderA).toBeLessThan(orderB) expect(orderB).toBeLessThan(orderC) }) test('multiple onError callbacks run in order', () => { const errorHook1 = vi.fn((error) => { throw error }) const errorHook2 = vi.fn() const errorHook3 = vi.fn() const { runErrorHooks, onError } = createRouterHooks() onError(errorHook1) onError(errorHook2) onError(errorHook3) const testError = new Error('Test error') const toRoute = createRoute({ name: 'routeA', component, href: '/', hash: '', }) const to = createResolvedRoute(toRoute, {}) const from: ResolvedRoute | null = null runErrorHooks(testError, { to, from, source: 'hook' }) expect(errorHook1).toHaveBeenCalledOnce() expect(errorHook2).toHaveBeenCalledOnce() expect(errorHook3).not.toHaveBeenCalled() const [order1] = errorHook1.mock.invocationCallOrder const [order2] = errorHook2.mock.invocationCallOrder expect(order1).toBeLessThan(order2) }) test('when onError callback calls reject, other onError callbacks do not run', () => { const errorHook1 = vi.fn((_error, { reject }) => { reject('NotFound') return true }) const errorHook2 = vi.fn(() => false) const errorHook3 = vi.fn(() => false) const { runErrorHooks, onError } = createRouterHooks() onError(errorHook1) onError(errorHook2) onError(errorHook3) const testError = new Error('Test error') const toRoute = createRoute({ name: 'routeA', component, href: '/', hash: '', }) const to = createResolvedRoute(toRoute, {}) const from: ResolvedRoute | null = null expect(() => { runErrorHooks(testError, { to, from, source: 'hook' }) }).toThrow() expect(errorHook1).toHaveBeenCalledOnce() expect(errorHook2).not.toHaveBeenCalled() expect(errorHook3).not.toHaveBeenCalled() }) test('when onError callback calls push, other onError callbacks do not run', () => { const errorHook1 = vi.fn((_error, { push }) => { push('/other') }) const errorHook2 = vi.fn() const errorHook3 = vi.fn() const { runErrorHooks, onError } = createRouterHooks() onError(errorHook1) onError(errorHook2) onError(errorHook3) const testError = new Error('Test error') const toRoute = createRoute({ id: Math.random().toString(), name: 'routeA', component, href: '/', hash: '', }) const to = createResolvedRoute(toRoute, {}) const from: ResolvedRoute | null = null expect(() => { runErrorHooks(testError, { to, from, source: 'hook' }) }).toThrow() expect(errorHook1).toHaveBeenCalledOnce() expect(errorHook2).not.toHaveBeenCalled() expect(errorHook3).not.toHaveBeenCalled() }) test('when onError callback calls replace, other onError callbacks do not run', () => { const errorHook1 = vi.fn((_error, { replace }) => { replace('/other') }) const errorHook2 = vi.fn() const errorHook3 = vi.fn() const { runErrorHooks, onError } = createRouterHooks() onError(errorHook1) onError(errorHook2) onError(errorHook3) const testError = new Error('Test error') const toRoute = createRoute({ name: 'routeA', component, href: '/', hash: '', }) const to = createResolvedRoute(toRoute, {}) const from: ResolvedRoute | null = null expect(() => { runErrorHooks(testError, { to, from, source: 'hook' }) }).toThrow() expect(errorHook1).toHaveBeenCalledOnce() expect(errorHook2).not.toHaveBeenCalled() expect(errorHook3).not.toHaveBeenCalled() }) ================================================ FILE: src/services/hooks.ts ================================================ import { HookLifecycle } from '@/types/hooks' import { ResolvedRoute } from '@/types/resolved' type RouteHookCondition = (to: ResolvedRoute, from: ResolvedRoute | null, depth: number) => boolean export const isRouteEnter: RouteHookCondition = (to, from, depth) => { const toMatches = to.matches const fromMatches = from?.matches ?? [] return toMatches.at(depth)?.id !== fromMatches.at(depth)?.id } export const isRouteLeave: RouteHookCondition = (to, from, depth) => { const toMatches = to.matches const fromMatches = from?.matches ?? [] return toMatches.at(depth)?.id !== fromMatches.at(depth)?.id } export const isRouteUpdate: RouteHookCondition = (to, from, depth) => { return to.matches.at(depth)?.id === from?.matches.at(depth)?.id } export function getRouteHookCondition(lifecycle: HookLifecycle): RouteHookCondition { switch (lifecycle) { case 'onBeforeRouteEnter': case 'onAfterRouteEnter': return isRouteEnter case 'onBeforeRouteUpdate': case 'onAfterRouteUpdate': return isRouteUpdate case 'onBeforeRouteLeave': case 'onAfterRouteLeave': return isRouteLeave default: throw new Error(`Switch is not exhaustive for lifecycle: ${lifecycle satisfies never}`) } } ================================================ FILE: src/services/insertBaseRoute.spec.ts ================================================ import { expect, test } from 'vitest' import { createRoute } from '@/services/createRoute' import { insertBaseRoute } from '@/services/insertBaseRoute' test.each([ [undefined], [''], ])('given empty or undefined base, returns route unmodified', (base) => { const route = createRoute({ name: 'foo', path: '/foo' }) const response = insertBaseRoute(route, base) expect(response).toMatchObject(route) }) test('given value for base, returns route with base prefixed', () => { const base = '/kitbag' const route = createRoute({ name: 'foo', path: '/foo' }) const response = insertBaseRoute(route, base) expect(response.stringify()).toBe('/kitbag/foo') }) ================================================ FILE: src/services/insertBaseRoute.ts ================================================ import { Route } from '@/types/route' import { stringHasValue } from '@/utilities/guards' import { createUrl } from '@/services/createUrl' import { combineUrl } from '@/services/combineUrl' export function insertBaseRoute(route: T, base?: string): T { if (!stringHasValue(base)) { return route } return { ...route, ...combineUrl(createUrl({ path: base }), route), } } ================================================ FILE: src/services/literal.ts ================================================ import { LiteralParam, ParamGetSet } from "@/types/paramTypes"; export function literal(value: T): ParamGetSet export function literal(param: LiteralParam): ParamGetSet { return { get: (value, { invalid }) => { if (`${param}` === value) { return param } throw invalid(`Expected value to be ${param}, received ${JSON.stringify(value)}`) }, set: (value, { invalid }) => { if (param !== value) { throw invalid(`Expected value to be literal ${param}, received ${JSON.stringify(value)}`) } return (value as LiteralParam).toString() }, } } ================================================ FILE: src/services/params.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { InvalidRouteParamValueError } from '@/errors/invalidRouteParamValueError' import { getParamValue, setParamValue } from '@/services/params' import { withDefault } from '@/services/withDefault' import { ParamGetSet, ParamGetter } from '@/types/paramTypes' describe('getParamValue', () => { test('given Boolean constructor Param, returns for correct value for Boolean', () => { expect(getParamValue('true', { param: Boolean })).toBe(true) expect(getParamValue('false', { param: Boolean })).toBe(false) expect(() => getParamValue('foo', { param: Boolean })).toThrow(InvalidRouteParamValueError) }) test('given Number constructor Param, returns for correct value for Number', () => { expect(getParamValue('1', { param: Number })).toBe(1) expect(getParamValue('1.5', { param: Number })).toBe(1.5) expect(() => getParamValue('foo', { param: Number })).toThrow(InvalidRouteParamValueError) }) test('Given a JSON Param, returns correct value for JSON', () => { expect(getParamValue('1', { param: JSON })).toBe(1) expect(getParamValue('"foo"', { param: JSON })).toBe('foo') expect(() => getParamValue('foo', { param: JSON })).toThrow(InvalidRouteParamValueError) }) test('given Date constructor Param, returns for correct value for Date', () => { expect(getParamValue('2024-05-16T21:13:56.842Z', { param: Date })).toMatchObject(new Date('2024-05-16T21:13:56.842Z')) expect(() => getParamValue('foo', { param: Date })).toThrow(InvalidRouteParamValueError) }) test('Given Regex Param, returns for correct value for RegExp', () => { const param = /yes/ expect(getParamValue('yes', { param })).toBe('yes') expect(() => getParamValue('no', { param })).toThrow(InvalidRouteParamValueError) expect(() => getParamValue('foo', { param })).toThrow(InvalidRouteParamValueError) }) test('given Literal Param, with matching value, returns value', () => { expect(getParamValue('foo', { param: 'foo' })).toBe('foo') expect(getParamValue('1', { param: 1 })).toBe(1) expect(getParamValue('true', { param: true })).toBe(true) }) test('given Literal Param, with non-matching value, throws InvalidRouteParamValueError', () => { expect(() => getParamValue('foo', { param: 'bar' })).toThrow(InvalidRouteParamValueError) expect(() => getParamValue('1', { param: 2 })).toThrow(InvalidRouteParamValueError) expect(() => getParamValue('true', { param: false })).toThrow(InvalidRouteParamValueError) }) test.each([ [undefined], [''], ])('Given Optional Param and string without value, returns undefined', (stringWithoutValue) => { const paramTypes = [String, Number, Boolean, Date, JSON, /regexp/g, () => 'getter'] for (const param of paramTypes) { expect(getParamValue(stringWithoutValue, { param, isOptional: true })).toBe(undefined) } }) test.each([ [undefined], [''], ])('Given Optional Param with default and string without value, returns default value', (stringWithoutValue) => { const paramTypes = [String, Number, Boolean, Date, JSON, /regexp/g, () => 'getter'] for (const param of paramTypes) { expect(getParamValue(stringWithoutValue, { param: withDefault(param, 'abc'), isOptional: true })).toBe('abc') } }) test('Given Custom Getter Param, returns for correct value for ParamGetter', () => { const param: ParamGetter<'yes'> = (value, { invalid }) => { if (value !== 'yes') { invalid() } return 'yes' } expect(getParamValue('yes', { param })).toBe('yes') expect(() => getParamValue('no', { param })).toThrowError() expect(() => getParamValue('foo', { param })).toThrowError() }) test('Given Custom GetSet, returns correct value for ParamGetSet', () => { const getter: ParamGetSet<'yes'> = { get: (value, { invalid }) => { if (value !== 'yes') { invalid() } return 'yes' }, set: (value) => value, } expect(getParamValue('yes', { param: getter })).toBe('yes') expect(() => getParamValue('no', { param: getter })).toThrowError() expect(() => getParamValue('foo', { param: getter })).toThrowError() }) }) describe('setParamValue', () => { test('Given Boolean Param, returns for correct value for Boolean', () => { expect(setParamValue(true, { param: Boolean })).toBe('true') expect(setParamValue(false, { param: Boolean })).toBe('false') expect(() => setParamValue('foo', { param: Boolean })).toThrow(InvalidRouteParamValueError) }) test('Given Number Param, returns for correct value for Number', () => { expect(setParamValue(1, { param: Number })).toBe('1') expect(setParamValue(1.5, { param: Number })).toBe('1.5') expect(() => setParamValue('foo', { param: Number })).toThrow(InvalidRouteParamValueError) }) test('given Date constructor Param, returns for correct value for Date', () => { expect(setParamValue(new Date('2024-05-16T21:13:56.842Z'), { param: Date })).toBe('2024-05-16T21:13:56.842Z') expect(() => setParamValue('foo', { param: Date })).toThrow(InvalidRouteParamValueError) }) test('Given a JSON Param, returns correct value for JSON', () => { expect(setParamValue(['foo'], { param: JSON })).toBe('["foo"]') expect(setParamValue(1.5, { param: JSON })).toBe('1.5') const circular: Record = { foo: 'bar' } circular.foo = circular expect(() => setParamValue(circular, { param: JSON })).toThrow(InvalidRouteParamValueError) }) test('Given Regex Param, returns value as String', () => { const param = /yes/ expect(setParamValue('yes', { param })).toBe('yes') }) test('Given Literal Param, with matching value, returns value', () => { expect(setParamValue('foo', { param: 'foo' })).toBe('foo') expect(setParamValue(1, { param: 1 })).toBe('1') expect(setParamValue(true, { param: true })).toBe('true') }) test('Given Literal Param, with non-matching value, throws InvalidRouteParamValueError', () => { expect(() => setParamValue('foo', { param: 'bar' })).toThrow(InvalidRouteParamValueError) expect(() => setParamValue(1, { param: 2 })).toThrow(InvalidRouteParamValueError) expect(() => setParamValue(true, { param: false })).toThrow(InvalidRouteParamValueError) }) test('Given Optional Param and value undefined, assigns empty string', () => { const paramTypes = [String, Number, Boolean, Date, JSON, /regexp/g, () => 'getter'] for (const param of paramTypes) { expect(setParamValue(undefined, { param, isOptional: true })).toBe('') expect(setParamValue(undefined, { param: withDefault(param, 'abc'), isOptional: true })).toBe('') } }) test('Given Getter Custom Param, returns value as String', () => { const param: ParamGetter = (value, { invalid }) => { if (value !== 'yes') { invalid() } return 'yes' } expect(setParamValue('yes', { param })).toBe('yes') }) test('Given Custom GetSet Param, returns correct value for ParamGetSet', () => { const param: ParamGetSet = { get: (value, { invalid }) => { if (value !== 'yes') { invalid() } return 'yes' }, set: (value, { invalid }) => { if (value !== 'yes') { invalid() } return 'yes' }, } expect(setParamValue('yes', { param })).toBe('yes') expect(() => setParamValue('no', { param })).toThrowError() expect(() => setParamValue('foo', { param })).toThrowError() }) }) ================================================ FILE: src/services/params.ts ================================================ import { InvalidRouteParamValueError, InvalidRouteParamValueErrorContext } from '@/errors/invalidRouteParamValueError' import { isParamWithDefault } from '@/services/withDefault' import { UrlParam } from '@/services/withParams' import { ExtractParamType, isLiteralParam, isParamGetSet, isParamGetter } from '@/types/params' import { LiteralParam, Param, ParamExtras, ParamGetSet } from '@/types/paramTypes' import { stringHasValue } from '@/utilities/guards' import { createZodParam, isZodParam } from './zod' import { createValibotParam, isValibotParam } from './valibot' import { literal } from './literal' export function getParam(params: Record, paramName: string): Param { return params[paramName] ?? String } function getParamExtras(seed: InvalidRouteParamValueErrorContext): ParamExtras { return { invalid: (message?: string) => { throw new InvalidRouteParamValueError({ ...seed, message }) }, } } const stringParam: ParamGetSet = { get: (value) => { return value }, set: (value, { invalid }) => { if (typeof value !== 'string') { throw invalid(`Expected string value, received ${JSON.stringify(value)}`) } return value }, } const booleanParam: ParamGetSet = { get: (value, { invalid }) => { if (value === 'true') { return true } if (value === 'false') { return false } throw invalid(`Expected boolean value, received ${JSON.stringify(value)}`) }, set: (value, { invalid }) => { if (typeof value !== 'boolean') { throw invalid(`Expected boolean value, received ${JSON.stringify(value)}`) } return value.toString() }, } const numberParam: ParamGetSet = { get: (value, { invalid }) => { const number = Number(value) if (isNaN(number)) { throw invalid(`Expected number value, received ${JSON.stringify(value)}`) } return number }, set: (value, { invalid }) => { if (typeof value !== 'number') { throw invalid(`Expected number value, received ${JSON.stringify(value)}`) } return value.toString() }, } const dateParam: ParamGetSet = { get: (value, { invalid }) => { const date = new Date(value) if (isNaN(date.getTime())) { throw invalid(`Expected date value, received ${JSON.stringify(value)}`) } return date }, set: (value, { invalid }) => { if (typeof value !== 'object' || !(value instanceof Date)) { throw invalid(`Expected date value, received ${JSON.stringify(value)}`) } return value.toISOString() }, } const jsonParam: ParamGetSet = { get: (value, { invalid }) => { try { return JSON.parse(value) } catch { throw invalid(`Expected JSON value, received "${value}"`) } }, set: (value, { invalid }) => { try { return JSON.stringify(value) } catch { throw invalid(`Expected JSON value, received "${value}"`) } }, } export function getParamValue(value: string | undefined, param: Partial>): ExtractParamType export function getParamValue(value: string | undefined, { param = String, isOptional = false }: Partial = {}): unknown { const extras = getParamExtras({ param, value, isGetter: true }) if (value === undefined || !stringHasValue(value)) { if (isParamWithDefault(param)) { return param.defaultValue } if (isOptional) { return undefined } throw extras.invalid(`Param is not optional, received ${JSON.stringify(value)}`) } if (param === String) { return stringParam.get(value, extras) } if (param === Boolean) { return booleanParam.get(value, extras) } if (param === Number) { return numberParam.get(value, extras) } if (param === Date) { return dateParam.get(value, extras) } if (param === JSON) { return jsonParam.get(value, extras) } if (isParamGetter(param)) { return param(value, extras) } if (isParamGetSet(param)) { return param.get(value, extras) } if (param instanceof RegExp) { if (param.test(value)) { return value } throw extras.invalid(`Expected value to match regex ${param.toString()}, received ${JSON.stringify(value)}`) } if( isLiteralParam(param)){ return literal(param).get(value, extras) } if (isZodParam(param)) { return createZodParam(param).get(value, extras) } if (isValibotParam(param)) { return createValibotParam(param).get(value, extras) } return value } export function safeGetParamValue(value: string | undefined, param: Partial>): ExtractParamType | undefined { try { return getParamValue(value, param) } catch (error) { if (error instanceof InvalidRouteParamValueError) { return undefined } throw error } } export function safeSetParamValue(value: unknown, param: Partial): string | undefined { try { return setParamValue(value, param) } catch (error) { if (error instanceof InvalidRouteParamValueError) { return undefined } throw error } } export function setParamValue(value: unknown, { param = String, isOptional = false }: Partial = {}): string { const extras = getParamExtras({ param, value, isSetter: true }) if (value === undefined) { if (isOptional) { return '' } throw extras.invalid(`Param is not optional, received ${JSON.stringify(value)}`) } if (param === Boolean) { return booleanParam.set(value as boolean, extras) } if (param === Number) { return numberParam.set(value as number, extras) } if (param === Date) { return dateParam.set(value as Date, extras) } if (param === JSON) { return jsonParam.set(value, extras) } if (isParamGetSet(param)) { return param.set(value, extras) } if (isLiteralParam(param)) { return literal(param).set(value as LiteralParam, extras) } if (isZodParam(param)) { return createZodParam(param).set(value, extras) } if (isValibotParam(param)) { return createValibotParam(param).set(value, extras) } try { return (value as any).toString() } catch { throw extras.invalid(`Unable to set param value, received ${JSON.stringify(value)}`) } } ================================================ FILE: src/services/paramsFinder.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { InvalidRouteParamValueError } from '@/errors/invalidRouteParamValueError' import { getParamValueFromUrl, setParamValueOnUrl } from '@/services/paramsFinder' import { withParams } from '@/services/withParams' describe('getParamValueFromUrl', () => { test('given path WITHOUT params, always returns undefined', () => { const path = withParams('/no-params', {}) const response = getParamValueFromUrl('/no-params', path, 'key') expect(response).toBe(undefined) }) test('given paramName that does NOT match param on route, always returns undefined', () => { const path = withParams('/key/[key]/not-in/[route]', {}) const response = getParamValueFromUrl('/key/ABC/not-in/123', path, 'fail') expect(response).toBe(undefined) }) test('given paramName that matches param on route but value is not present, always returns undefined', () => { const path = withParams('/simple/[simple]', {}) const response = getParamValueFromUrl('/simple', path, 'simple') expect(response).toBe(undefined) }) test('given paramName that matches param on route, returns value', () => { const path = withParams('/simple/[simple]', {}) const response = getParamValueFromUrl('/simple/ABC', path, 'simple') expect(response).toBe('ABC') }) test('given multiple params, uses wildcards for non-selected param name', () => { const path = withParams('/[str]/[num]/[bool]', {}) const response = getParamValueFromUrl('/ABC/123/true', path, 'num') expect(response).toBe('123') }) test('given multiple params, where optional params are omitted from path, still finds the required param', () => { const path = withParams('/[required]/[?optional]', {}) const response = getParamValueFromUrl('/ABC/', path, 'required') expect(response).toBe('ABC') }) test('given path with greedy param, extracts multi-segment value for greedy param', () => { const path = withParams('/[id]/[rest*]/suffix', {}) expect(getParamValueFromUrl('/1/a/b/c/suffix', path, 'rest')).toBe('a/b/c') expect(getParamValueFromUrl('/1/a/b/c/suffix', path, 'id')).toBe('1') }) }) describe('setParamValueOnUrl', () => { test('given path WITHOUT params, always returns url as it was passed', () => { const path = withParams('/no-params', {}) const response = setParamValueOnUrl('/no-params', path, 'key', 'ABC') expect(response).toBe('/no-params') }) test('given paramName that does NOT match param on route, returns url unmodified', () => { const path = withParams('/key/[key]/not-in/[route]', {}) const response = setParamValueOnUrl('/key/[key]/not-in/[route]', path, 'fail', 'ABC') expect(response).toBe('/key/[key]/not-in/[route]') }) test('given paramName that matches param on route, returns url with param replaced', () => { const path = withParams('/simple/[simple]', {}) const response = setParamValueOnUrl('/simple/[simple]', path, 'simple', 'ABC') expect(response).toBe('/simple/ABC') }) test('given paramName that matches param on route and value is not present, throws InvalidRouteParamValueError', () => { const path = withParams('/simple/[simple]', {}) const action: () => void = () => setParamValueOnUrl('/simple/[simple]', path, 'simple', undefined) expect(action).toThrowError(InvalidRouteParamValueError) }) }) ================================================ FILE: src/services/paramsFinder.ts ================================================ import { setParamValue } from '@/services/params' import { getCaptureGroups, getParamRegexPattern, replaceIndividualParamWithCaptureGroup, replaceParamSyntaxWithCatchAlls } from '@/services/routeRegex' import { UrlPart } from './withParams' export function getParamValueFromUrl(url: string, path: UrlPart, paramName: string): string | undefined { const paramNameCaptureGroup = replaceIndividualParamWithCaptureGroup(path, paramName) const otherParamsCatchAll = replaceParamSyntaxWithCatchAlls(paramNameCaptureGroup) const [paramValue] = getCaptureGroups(url, new RegExp(otherParamsCatchAll, 'g')) return paramValue } export function setParamValueOnUrl(url: string, path: UrlPart, paramName: string, value: unknown): string { const paramValue = setParamValue(value, path.params[paramName]) return url.replace(getParamRegexPattern(paramName), paramValue) } ================================================ FILE: src/services/queryParamFilter.spec.ts ================================================ import { expect, test } from 'vitest' import { filterQueryParams } from './queryParamFilter' test('given no overlap, returns source unmodified', () => { const source = new URLSearchParams('foo=123') const exclude = new URLSearchParams('bar=456') const response = filterQueryParams(source, exclude) expect(response.toString()).toBe('foo=123') }) test('given exclude with same key but different value, returns source unmodified', () => { const source = new URLSearchParams('foo=123') const exclude = new URLSearchParams('foo=456') const response = filterQueryParams(source, exclude) expect(response.toString()).toBe('foo=123') }) test('given exclude with same key and value, returns source without duplicates', () => { const source = new URLSearchParams('foo=123&bar=456') const exclude = new URLSearchParams('foo=123') const response = filterQueryParams(source, exclude) expect(response.toString()).toBe('bar=456') }) test('given source with multiple values for same key, only removes matching value', () => { const source = new URLSearchParams('foo=123&foo=456') const exclude = new URLSearchParams('foo=123') const response = filterQueryParams(source, exclude) expect(response.toString()).toBe('foo=456') }) ================================================ FILE: src/services/queryParamFilter.ts ================================================ import { QuerySource } from '@/types/querySource' export function filterQueryParams(source: QuerySource, exclude: QuerySource): URLSearchParams { const sourceParams = new URLSearchParams(source) const excludeParams = new URLSearchParams(exclude) for (const [key, value] of excludeParams.entries()) { sourceParams.delete(key, value) } return sourceParams } ================================================ FILE: src/services/routeRegex.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { generateRouteHostRegexPattern, generateRoutePathRegexPattern, generateRouteQueryRegexPatterns, getParamName, regexCaptureAll, regexCatchAll, regexGreedyCatchAll, regexGreedyCaptureAll, replaceIndividualParamWithCaptureGroup, splitByMatches } from '@/services/routeRegex' import { withParams } from './withParams' describe('generateRouteHostRegexPattern', () => { test('given host without params, returns unmodified value with start and end markers', () => { const host = 'https://www.kitbag.io' const result = generateRouteHostRegexPattern(host) const expected = new RegExp('^https://www\\.kitbag\\.io$', 'i') expect(result.toString()).toBe(expected.toString()) }) test('given host with params, returns value with params replaced with catchall', () => { const host = 'https://[subdomain].kitbag.io' const result = generateRouteHostRegexPattern(host) const expected = new RegExp(`^https://${regexCatchAll}\\.kitbag\\.io$`, 'i') expect(result.toString()).toBe(expected.toString()) }) test('given host with optional params, returns value with params replaced with catchall', () => { const host = 'https://[?subdomain].kitbag.io' const result = generateRouteHostRegexPattern(host) const expected = new RegExp(`^https://${regexCatchAll}\\.kitbag\\.io$`, 'i') expect(result.toString()).toBe(expected.toString()) }) test('given host with regex characters outside of params, escapes regex characters', () => { const host = 'https://www.with$]regex[params*' const result = generateRouteHostRegexPattern(host) const expected = new RegExp('^https://www\\.with\\$\\]regex\\[params\\*$', 'i') expect(result.toString()).toBe(expected.toString()) }) }) describe('generateRoutePathRegexPattern', () => { test('given path without params, returns unmodified value with start and end markers', () => { const path = 'parent/child/grandchild' const result = generateRoutePathRegexPattern(path) const expected = new RegExp('^parent/child/grandchild$', 'i') expect(result.toString()).toBe(expected.toString()) }) test('given path with params, returns value with params replaced with catchall', () => { const path = 'parent/child/[childParam]/grand-child/[grandChild123]' const result = generateRoutePathRegexPattern(path) const expected = new RegExp(`^parent/child/${regexCatchAll}/grand-child/${regexCatchAll}$`, 'i') expect(result.toString()).toBe(expected.toString()) }) test('given path with optional params, returns value with params replaced with catchall', () => { const path = 'parent/child/[?childParam]/grand-child/[?grandChild123]' const result = generateRoutePathRegexPattern(path) const expected = new RegExp(`^parent/child/${regexCatchAll}/grand-child/${regexCatchAll}$`, 'i') expect(result.toString()).toBe(expected.toString()) }) test('given path with regex characters outside of params, escapes regex characters', () => { const path = 'path.with$]regex[params*' const result = generateRoutePathRegexPattern(path) const expected = new RegExp('^path\\.with\\$\\]regex\\[params\\*$', 'i') expect(result.toString()).toBe(expected.toString()) }) test('given path with greedy param, uses greedy catch-all for that segment', () => { const path = 'parent/[a]/[b*]/[c]' const result = generateRoutePathRegexPattern(path) const expected = new RegExp(`^parent/${regexCatchAll}/${regexGreedyCatchAll}/${regexCatchAll}$`, 'i') expect(result.toString()).toBe(expected.toString()) }) }) describe('generateRouteQueryRegexPatterns', () => { test('given query without params, returns unmodified value with start and end markers', () => { const result = generateRouteQueryRegexPatterns('') expect(result).toMatchObject([]) }) test('given query with required params, returns value with params replaced with catchall', () => { const query = 'dynamic=[first]&static=params&another=[second]' const result = generateRouteQueryRegexPatterns(query) expect(result).toMatchObject([new RegExp(`dynamic=${regexCaptureAll}`), new RegExp('static=params'), new RegExp(`another=${regexCaptureAll}`)]) }) test('given query with optional params, returns value without params', () => { const query = 'dynamic=[?first]&static=params&another=[?second]' const result = generateRouteQueryRegexPatterns(query) expect(result).toMatchObject([new RegExp('static=params')]) }) test('given query with regex characters outside of params, escapes regex characters', () => { const query = 'query=$with&normal=[param]®ex*chars=)throughout[&' const result = generateRouteQueryRegexPatterns(query) expect(result.map((pattern) => pattern.toString())).toMatchObject([ '/query=\\$with(&|$)/i', `/normal=${regexCatchAll}(&|$)/i`, '/regex\\*chars=\\)throughout\\[(&|$)/i', ]) }) }) describe('getParamName', () => { test('given string with optional param name syntax, returns param name', () => { const paramName = 'foo' const response = getParamName(`[?${paramName}]`) expect(response).toBe(paramName) }) test('given string with param name syntax, returns param name', () => { const paramName = 'foo' const response = getParamName(`[${paramName}]`) expect(response).toBe(paramName) }) test('given string with greedy param name syntax, returns base param name', () => { const response = getParamName('[foo*]') expect(response).toBe('foo') }) test('given string with optional greedy param name syntax, returns base param name', () => { const response = getParamName('[?foo*]') expect(response).toBe('foo') }) test.each([ ['foo'], ['?foo'], ['?:*foo'], ])('given string that is not param syntax, returns undefined', (paramName) => { const response = getParamName(paramName) expect(response).toBe(undefined) }) }) describe('replaceIndividualParamWithCaptureGroup', () => { test('given normal param, replaces with segment capture pattern', () => { const path = withParams('/[id]/suffix', { id: String }) const result = replaceIndividualParamWithCaptureGroup(path, 'id') expect(result).toBe(`/${regexCaptureAll}/suffix`) }) test('given greedy param, replaces with greedy capture pattern', () => { const path = withParams('/[rest*]/suffix', { rest: String }) const result = replaceIndividualParamWithCaptureGroup(path, 'rest') expect(result).toBe(`/${regexGreedyCaptureAll}/suffix`) }) }) describe('splitByMatches', () => { test('given string without matches, returns full string', () => { const value = 'string without matches' const pattern = /will-not-find/g const response = splitByMatches(value, pattern) expect(response).toMatchObject([value]) }) test('given string with match at the beginning, returns match the rest', () => { const value = 'at-beginning, string with match' const pattern = /at-beginning/g const response = splitByMatches(value, pattern) expect(response).toMatchObject(['at-beginning', ', string with match']) }) test('given string with match at the end, returns the rest then match', () => { const value = 'string with match at-end' const pattern = /at-end/g const response = splitByMatches(value, pattern) expect(response).toMatchObject(['string with match ', 'at-end']) }) test('given string with matches in the middle, returns array of matches and everything in between', () => { const value = 'found-throughout string found-throughout with match found-throughout' const pattern = /found-throughout/g const response = splitByMatches(value, pattern) expect(response).toMatchObject(['found-throughout', ' string ', 'found-throughout', ' with match ', 'found-throughout']) }) }) ================================================ FILE: src/services/routeRegex.ts ================================================ import { paramEnd, paramStart } from '@/types/params' import { stringHasValue } from '@/utilities/guards' import { UrlPart } from '@/services/withParams' export const paramRegex = `\\${paramStart}\\??([\\w-_]+)\\*?\\${paramEnd}` export const optionalParamRegex = `\\${paramStart}\\?([\\w-_]+)\\*?\\${paramEnd}` export const requiredParamRegex = `\\${paramStart}([\\w-_]+)\\*?\\${paramEnd}` export const greedyParamRegex = `\\${paramStart}\\??([\\w-_]+)\\*\\${paramEnd}` export const regexCatchAll = '[^/]*' export const regexGreedyCatchAll = '.*' export const regexCaptureAll = '([^/]*)' export const regexGreedyCaptureAll = '(.*)' function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } export function splitByMatches(string: string, regexp: RegExp): string[] { const matches = Array.from(string.matchAll(regexp)) if (matches.length === 0) { return [string] } let lastSlice = 0 const slices = matches.reduce((slices, match) => { const slice = escapeRegExp(string.slice(lastSlice, match.index)) if (slice.length) { slices.push(slice) } const [value] = match slices.push(value) lastSlice = match.index + value.length return slices }, []) const last = string.slice(lastSlice) if (last) { slices.push(last) } return slices } export function generateRouteHostRegexPattern(host: string): RegExp { const hostRegex = replaceParamSyntaxWithCatchAllsAndEscapeRest(host) return new RegExp(`^${hostRegex || '.*'}$`, 'i') } export function generateRoutePathRegexPattern(path: string): RegExp { const pathRegex = replaceParamSyntaxWithCatchAllsAndEscapeRest(path) return new RegExp(`^${pathRegex}$`, 'i') } export function generateRouteHashRegexPattern(hash: string): RegExp { const cleanValue = hash.replace(/^#*/, '') const hashRegex = replaceParamSyntaxWithCatchAllsAndEscapeRest(cleanValue) return new RegExp(`^#?${hashRegex || '.*'}$`, 'i') } export function generateRouteQueryRegexPatterns(query: string): RegExp[] { const queryParams = new URLSearchParams(query) return Array .from(queryParams.entries()) .filter(([, value]) => !isOptionalParamSyntax(value)) .map(([key, value]) => { const valueRegex = replaceParamSyntaxWithCatchAllsAndEscapeRest(value) return new RegExp(`${escapeRegExp(key)}=${valueRegex}(&|$)`, 'i') }) } function replaceParamSyntaxWithCatchAllsAndEscapeRest(value: string): string { return splitByMatches(value, new RegExp(paramRegex, 'g')) .map((slice) => { const isParam = slice.startsWith(paramStart) return isParam ? replaceParamSyntaxWithCatchAlls(slice) : escapeRegExp(slice) }) .join('') } export function replaceParamSyntaxWithCatchAlls(value: string): string { return value.replace(new RegExp(paramRegex, 'g'), (match) => { return isGreedyParamSyntax(match) ? regexGreedyCatchAll : regexCatchAll }) } export function replaceIndividualParamWithCaptureGroup(path: UrlPart, paramName: string): string { const pattern = getParamRegexPattern(paramName) const { isGreedy = false } = path.params[paramName] ?? {} const capturePattern = isGreedy ? regexGreedyCaptureAll : regexCaptureAll return path.value.replace(pattern, capturePattern) } export function isOptionalParamSyntax(value: string): boolean { return new RegExp(optionalParamRegex, 'g').test(value) } export function isRequiredParamSyntax(value: string): boolean { return new RegExp(requiredParamRegex, 'g').test(value) } export function isGreedyParamSyntax(value: string): boolean { return new RegExp(greedyParamRegex, 'g').test(value) } export function getParamName(value: string): string | undefined { const [paramName] = getCaptureGroups(value, new RegExp(paramRegex, 'g')) return paramName } export function getParamRegexPattern(paramName: string): RegExp { return new RegExp(`\\${paramStart}\\??${paramName}\\*?\\${paramEnd}`, 'g') } export function getCaptureGroups(value: string, pattern: RegExp): (string | undefined)[] { const matches = Array.from(value.matchAll(pattern)) return matches.flatMap(([, ...values]) => values.map((value) => { return stringHasValue(value) ? value : '' })) } ================================================ FILE: src/services/state.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { getStateValues, setStateValues } from '@/services/state' import { withDefault } from '@/services/withDefault' describe('setStateValues', () => { test.each([ [null], [undefined], ['{}'], [[]], ])('given state that is not expected format, returns empty object', (state) => { const params = { foo: Number, } const response = setStateValues(params, state) expect(response).toMatchObject({}) }) test('given state missing the expected key, returns empty string', () => { const params = { foo: Number, } const state = { bar: 'abc', } const response = setStateValues(params, state) expect(response).toMatchObject({ foo: '', }) }) test('given state with the expected key, returns parsed value', () => { const params = { foo: Number, } const state = { foo: 456, } const response = setStateValues(params, state) expect(response).toMatchObject({ foo: '456', }) }) }) describe('getStateValues', () => { test.each([ [null], [undefined], ['{}'], [[]], ])('given state that is not expected format, returns empty object', (state) => { const params = { foo: Number, } const response = getStateValues(params, state) expect(response).toMatchObject({}) }) test('given state missing the expected key without default, returns undefined', () => { const params = { foo: Number, } const state = { bar: 'abc', } const response = getStateValues(params, state) expect(response).toMatchObject({ foo: undefined, }) }) test('given state missing the expected key with default, returns default value', () => { const params = { foo: withDefault(Number, 123), } const state = { bar: 'abc', } const response = getStateValues(params, state) expect(response).toMatchObject({ foo: 123, }) }) test('given state with the expected key, returns parsed value', () => { const params = { foo: Number, } const state = { foo: '456', } const response = getStateValues(params, state) expect(response).toMatchObject({ foo: 456, }) }) }) ================================================ FILE: src/services/state.ts ================================================ import { getParamValue, setParamValue } from '@/services/params' import { Param } from '@/types/paramTypes' function stateIsRecord(state: unknown): state is Record { return !!state && typeof state === 'object' } const paramOptions = { isOptional: true, isGreedy: false } function getStateValue(state: unknown, key: string, param: Param): unknown { if (stateIsRecord(state) && key in state) { const value = state[key] if (typeof value === 'string') { return getParamValue(value, { param, ...paramOptions }) } return value } return getParamValue(undefined, { param, ...paramOptions }) } /** * This function is used to get the values inside the state converted from string values into the correct type. */ export function getStateValues(params: Record, state: unknown): Record { const values: Record = {} for (const [key, param] of Object.entries(params)) { const paramValue = getStateValue(state, key, param) values[key] = paramValue } return values } /** * This function is used to get the values inside the state converted from string values into the correct type. */ function setStateValue(state: unknown, key: string, param: Param): string | undefined { if (stateIsRecord(state) && key in state) { const value = state[key] return setParamValue(value, { param, ...paramOptions }) } return setParamValue(undefined, { param, ...paramOptions }) } /** * This function is used to set the values inside the state to have string values, stored in history. */ export const setStateValues = (params: Record, state: unknown): Record => { const values: Record = {} for (const [key, param] of Object.entries(params)) { const paramValue = setStateValue(state, key, param) values[key] = paramValue } return values } ================================================ FILE: src/services/tupleOf.spec.ts ================================================ import { expect, test } from 'vitest' import { tupleOf } from '@/services/tupleOf' import { getParamValue, setParamValue } from '@/services/params' import { InvalidRouteParamValueError } from '@/errors/invalidRouteParamValueError' test.each([ ['23,true,foo', [23, true, 'foo']], ['-2,false,bar', [-2, false, 'bar']], ])('given an array of params with valid values, returns an array of values', (input, expected) => { const array = tupleOf([Number, Boolean, String]) const result = getParamValue(input, { param: array }) expect(result).toEqual(expected) }) test.each([ {}, true, '', Infinity, ])('given value is %s not an array, throws InvalidRouteParamValueError', (value) => { const array = tupleOf([Number, Boolean]) const action: () => void = () => setParamValue(value, { param: array }) expect(action).toThrow('Expected a tuple') }) test('given value with too few values, throws InvalidRouteParamValueError', () => { const array = tupleOf([Number, Number]) const action: () => void = () => setParamValue([1], { param: array }) expect(action).toThrow('Expected tuple with 2 values') }) test('given value with too many values, throws InvalidRouteParamValueError', () => { const array = tupleOf([Number, Number]) const action: () => void = () => setParamValue([1, 2, 3], { param: array }) expect(action).toThrow('Expected tuple with 2 values') }) test.each([ ['23'], ['true,23'], ])('given an array of params with invalid value %s, throws InvalidRouteParamValueError', (value) => { const array = tupleOf([Number, Boolean]) const action: () => void = () => getParamValue(value, { param: array }) expect(action).toThrow(InvalidRouteParamValueError) }) ================================================ FILE: src/services/tupleOf.ts ================================================ import { Param, ParamGetSet } from '@/types/paramTypes' import { ExtractParamType } from '@/types/params' import { getParamValue, setParamValue } from '@/services/params' type TupleOfOptions = { separator?: string, } const defaultOptions = { separator: ',', } satisfies TupleOfOptions type TupleOf = { [K in keyof T]: ExtractParamType } export function tupleOf(params: T, options: TupleOfOptions = {}): ParamGetSet> { const { separator } = { ...defaultOptions, ...options } return { get: (value) => { const values = value.split(separator) return params.map((param, index) => getParamValue(values.at(index), { param })) as TupleOf }, set: (value, { invalid }) => { if (!Array.isArray(value)) { throw invalid('Expected a tuple') } if (value.length !== params.length) { throw invalid(`Expected tuple with ${params.length} values but received ${value.length} values`) } return params.map((param, index) => setParamValue(value.at(index), { param })).join(separator) }, } } ================================================ FILE: src/services/unionOf.spec.ts ================================================ import { expect, test, vi } from 'vitest' import { InvalidRouteParamValueError } from '@/errors/invalidRouteParamValueError' import { unionOf } from '@/services/unionOf' import { getParamValue } from './params' function throwsInvalidRouteParamValueError(): () => never { return () => { throw new InvalidRouteParamValueError() } } test('given several params, calls each until one returns a value', () => { const aParam = vi.fn().mockImplementationOnce(throwsInvalidRouteParamValueError()) const bParam = vi.fn().mockImplementationOnce(throwsInvalidRouteParamValueError()) const cParam = vi.fn().mockImplementationOnce(() => 'works!') const dParam = vi.fn().mockImplementationOnce(() => 'also works!') const union = unionOf([aParam, bParam, cParam, dParam]) const result = getParamValue('foo', { param: union, isOptional: false, isGreedy: false }) expect(aParam).toHaveBeenCalledTimes(1) expect(bParam).toHaveBeenCalledTimes(1) expect(cParam).toHaveBeenCalledTimes(1) expect(dParam).not.toHaveBeenCalled() expect(result).toBe('works!') }) test('given no param returns value, throws InvalidRouteParamValueError', () => { const aParam = vi.fn().mockImplementationOnce(throwsInvalidRouteParamValueError()) const bParam = vi.fn().mockImplementationOnce(throwsInvalidRouteParamValueError()) const cParam = vi.fn().mockImplementationOnce(throwsInvalidRouteParamValueError()) const dParam = vi.fn().mockImplementationOnce(throwsInvalidRouteParamValueError()) const union = unionOf([aParam, bParam, cParam, dParam]) const action: () => void = () => getParamValue('foo', { param: union, isOptional: false, isGreedy: false }) expect(action).toThrow('Value "foo" does not satisfy any of the possible values') expect(aParam).toHaveBeenCalledTimes(1) expect(bParam).toHaveBeenCalledTimes(1) expect(cParam).toHaveBeenCalledTimes(1) expect(dParam).toHaveBeenCalledTimes(1) }) test('given a param that throws something other than InvalidRouteParamValueError, throws the error', () => { const param = vi.fn().mockImplementationOnce(() => { throw new Error('Something went wrong') }) const union = unionOf([param]) const action: () => void = () => getParamValue('foo', { param: union, isOptional: false, isGreedy: false }) expect(action).toThrow('Something went wrong') }) test.each([ [Number, '1', 1], [Boolean, 'true', true], [JSON, '{"foo": "bar"}', { foo: 'bar' }], [Date, '2021-01-01T00:00:00.000Z', new Date('2021-01-01T00:00:00.000Z')], [/foo/, 'foo', 'foo'], [String, 'foo', 'foo'], ])('works with param of built-in type %s', (param, input, output) => { const union = unionOf([param]) const result = getParamValue(input, { param: union, isOptional: false, isGreedy: false }) if (typeof output === 'object') { expect(result).toEqual(output) } else { expect(result).toBe(output) } }) test.each([ 23, 'foo', true, ])('works with literal param of type %s', (value) => { const union = unionOf([value]) const result = getParamValue(value.toString(), { param: union, isOptional: false, isGreedy: false }) expect(result).toBe(value) }) ================================================ FILE: src/services/unionOf.ts ================================================ import { Param, ParamGetSet } from '@/types/paramTypes' import { safeGetParamValue, safeSetParamValue } from '@/services/params' import { ExtractParamType } from '@/types/params' export function unionOf(params: T): ParamGetSet> export function unionOf(params: Param[]): ParamGetSet { return { get: (value, { invalid }) => { for (const param of params) { const result = safeGetParamValue(value, { param, isOptional: false, isGreedy: false }) if (result !== undefined) { return result } } throw invalid(`Value "${value}" does not satisfy any of the possible values`) }, set: (value, { invalid }) => { for (const param of params) { const result = safeSetParamValue(value, { param, isOptional: false, isGreedy: false }) if (result !== undefined) { return result } } throw invalid(`Value "${value}" does not satisfy any of the possible values`) }, } } ================================================ FILE: src/services/urlParser.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { parseUrl, stringifyUrl, updateUrl } from '@/services/urlParser' describe('parseUrl', () => { test('given relative url, returns host and protocol undefined', () => { const url = '/foo?bar=123' const parts = parseUrl(url) expect(parts.host).toBe(undefined) }) test('given absolute url with path, returns everything up to path', () => { const url = 'https://kitbag.dev/foo' const parts = parseUrl(url) expect(parts.host).toBe('https://kitbag.dev') expect(parts.path).toBe('/foo') expect(parts.query.toString()).toBe('') expect(parts.hash).toBe('') }) test('given absolute url with path and query, returns everything up to search', () => { const url = 'https://kitbag.dev/foo?bar=123' const parts = parseUrl(url) expect(parts.host).toBe('https://kitbag.dev') expect(parts.path).toBe('/foo') expect(parts.query.toString()).toBe('bar=123') expect(parts.hash).toBe('') }) test('given absolute url with path, query, and hash, returns everything', () => { const url = 'https://kitbag.dev/foo?bar=123#zoo' const parts = parseUrl(url) expect(parts.host).toBe('https://kitbag.dev') expect(parts.path).toBe('/foo') expect(parts.query.toString()).toBe('bar=123') expect(parts.hash).toBe('#zoo') }) }) describe('stringifyUrl', () => { test('given parts, returns stringified url', () => { const parts = { host: 'https://kitbag.dev', path: '/foo', query: '?bar=123', hash: '#zoo', } const url = stringifyUrl(parts) expect(url).toBe('https://kitbag.dev/foo?bar=123#zoo') }) }) describe('updateUrl', () => { test.each([ [{ host: 'https://kitbag.com' }, { host: 'https://kitbag.dev' }], [{ host: 'https://kitbag.dev' }, { host: '' }], [{ host: 'https://kitbag.dev' }, { host: undefined }], ])('given previous host (%s) and updated host (%s), returns updated host', (previous, updated) => { const url = updateUrl(previous, updated) expect(url.host).toBe('https://kitbag.dev') }) test.each([ [{ path: '/bar' }, { path: '/foo' }], [{ path: '/foo' }, { path: '' }], [{ path: '/foo' }, { path: undefined }], ])('given previous path (%s) and updated path (%s), returns updated path', (previous, updated) => { const url = updateUrl(previous, updated) expect(url.path).toBe('/foo') }) test.each([ [{ query: new URLSearchParams('?foo=456') }, { query: new URLSearchParams('?bar=123') }], [{ query: new URLSearchParams('?foo=456') }, { query: '?bar=123' }], [{ query: new URLSearchParams('?foo=456&bar=123') }, { query: '' }], [{ query: new URLSearchParams('?foo=456&bar=123') }, { query: undefined }], ])('given previous query (%s) and updated query (%s), returns updated query', (previous, updated) => { const url = updateUrl(previous, updated) expect(url.query.toString()).toBe('foo=456&bar=123') }) test.each([ [{ hash: 'bar' }, { hash: 'foo' }], [{ hash: 'foo' }, { hash: '' }], [{ hash: 'foo' }, { hash: undefined }], ])('given previous hash (%s) and updated hash (%s), returns updated hash', (previous, updated) => { const url = updateUrl(previous, updated) expect(url.hash).toBe('foo') }) }) describe('stringifyUrl', () => { test('given parts without host, or path, returns forward slash to satisfy Url', () => { const url = stringifyUrl({}) expect(url).toBe('/') }) test.each(['foo', '/foo'])('given parts with path, returns value with path', (path) => { const parts = { host: 'https://kitbag.dev', path, } const url = stringifyUrl(parts) expect(url).toBe('https://kitbag.dev/foo') }) test.each(['?bar=123', 'bar=123'])('given parts with query, returns value with query', (query) => { const parts = { host: 'https://kitbag.dev', query, } const url = stringifyUrl(parts) expect(url).toBe('https://kitbag.dev/?bar=123') }) test.each(['bar', '#bar'])('given parts with hash, returns value with hash', (hash) => { const parts = { host: 'https://kitbag.dev', hash, } const url = stringifyUrl(parts) expect(url).toBe('https://kitbag.dev/#bar') }) test('given parts without host, returns url starting with forward slash', () => { const parts = { path: '/foo', query: '?bar=123', } const url = stringifyUrl(parts) expect(url).toBe('/foo?bar=123') }) }) ================================================ FILE: src/services/urlParser.ts ================================================ import { QuerySource } from '@/types/querySource' import { asUrlString, UrlString } from '@/types/urlString' import { stringHasValue } from '@/utilities/guards' import { combineUrlSearchParams } from '@/utilities/urlSearchParams' type UrlParts = { host?: string, path: string, query: URLSearchParams, hash: string, } type UrlPartsInput = { host?: string, path?: string, query?: QuerySource, hash?: string, } // https://en.wikipedia.org/wiki/.invalid const FALLBACK_HOST = 'https://internal.invalid' export function stringifyUrl(parts: UrlPartsInput): UrlString { const url = new URL(parts.host ?? FALLBACK_HOST, FALLBACK_HOST) url.pathname = parts.path ?? '' url.search = new URLSearchParams(parts.query).toString() url.hash = parts.hash ?? '' return asUrlString(url.toString().replace(new RegExp(`^${FALLBACK_HOST}/*`), '/')) } export function parseUrl(value: string): UrlParts { const isRelative = !value.startsWith('http') return isRelative ? createRelativeUrl(value) : createAbsoluteUrl(value) } export function updateUrl(url: string, updates: UrlPartsInput): UrlString export function updateUrl(url: Partial, updates: UrlPartsInput): UrlParts export function updateUrl(url: string | Partial, updates: UrlPartsInput): string | UrlParts { if (typeof url === 'string') { const updated = updateUrl(parseUrl(url), updates) return stringifyUrl(updated) } const updatedQuery = new URLSearchParams(updates.query) return { host: stringHasValue(updates.host) ? updates.host : url.host, path: stringHasValue(updates.path) ? updates.path : url.path ?? '', query: combineUrlSearchParams(url.query, updatedQuery), hash: stringHasValue(updates.hash) ? updates.hash : url.hash ?? '', } } function createAbsoluteUrl(value: string): UrlParts { const { protocol, host, pathname, searchParams, hash } = new URL(value, value) return { host: `${protocol}//${host}`, path: pathname, query: searchParams, hash, } } function createRelativeUrl(value: string): UrlParts { const { pathname, searchParams, hash } = new URL(value, FALLBACK_HOST) return { path: pathname, query: searchParams, hash, } } ================================================ FILE: src/services/valibot.spec-d.ts ================================================ import { expectTypeOf, test } from 'vitest' import { withParams } from './withParams' import { string, boolean } from 'valibot' import { ExtractParamType } from '@/types/params' test('withParams accepts valibot schemas', () => { const schema = string() const { params } = withParams('/[foo]', { foo: schema }) expectTypeOf(params.foo).toEqualTypeOf<{ param: typeof schema, isOptional: false, isGreedy: false }>() }) test('ExtractParamType returns the correct type for valibot params', () => { const param = boolean() type Input = ExtractParamType expectTypeOf().toEqualTypeOf() }) ================================================ FILE: src/services/valibot.spec.ts ================================================ import { safeGetParamValue, safeSetParamValue } from './params' import { test, expect } from 'vitest' import * as v from 'valibot' enum Fruits { Apple = 0, Banana = 1 } const discriminatedUnion = v.variant('type', [ v.object({ type: v.literal('one'), value: v.string() }), v.object({ type: v.literal('two'), value: v.number() }), ]) test.each([ { schema: v.literal('foo'), string: 'foo', parsed: 'foo' }, { schema: v.literal(1), string: '1', parsed: 1 }, { schema: v.literal(true), string: 'true', parsed: true }, { schema: v.string(), string: 'foo', parsed: 'foo' }, { schema: v.number(), string: '1', parsed: 1 }, { schema: v.boolean(), string: 'true', parsed: true }, { schema: v.date(), string: '2022-01-12T00:00:00.000Z', parsed: new Date('2022-01-12T00:00:00.000Z') }, { schema: v.object({ foo: v.string() }), string: '{"foo":"bar"}', parsed: { foo: 'bar' } }, { schema: v.object({ foo: v.nullable(v.string()) }), string: '{"foo":null}', parsed: { foo: null } }, { schema: v.object({ foo: v.optional(v.string()) }), string: '{}', parsed: {} }, { schema: v.enum(Fruits), string: '0', parsed: Fruits.Apple }, { schema: v.array(v.string()), string: '["foo","bar"]', parsed: ['foo', 'bar'] }, { schema: v.tuple([v.string(), v.number()]), string: '["foo",1]', parsed: ['foo', 1] }, { schema: v.union([v.string(), v.number()]), string: 'foo', parsed: 'foo' }, { schema: v.union([v.string(), v.number()]), string: '1', parsed: 1 }, { schema: v.union([v.string(), v.object({ foo: v.string() })]), string: '{"foo":"bar"}', parsed: { foo: 'bar' } }, { schema: discriminatedUnion, string: '{"type":"one","value":"foo"}', parsed: { type: 'one', value: 'foo' } }, { schema: discriminatedUnion, string: '{"type":"two","value":1}', parsed: { type: 'two', value: 1 } }, { schema: v.record(v.string(), v.object({ foo: v.string() })), string: '{"one":{"foo":"bar"}}', parsed: { one: { foo: 'bar' } } }, { schema: v.map(v.string(), v.number()), string: '[["one",1]]', parsed: new Map([['one', 1]]) }, { schema: v.set(v.number()), string: '[1,2,3]', parsed: new Set([1, 2, 3]) }, ])('given $schema.type, returns $parsed for $string', async ({ schema, string, parsed }) => { if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') { expect(safeGetParamValue(string, { param: schema })).toBe(parsed) expect(safeSetParamValue(parsed, { param: schema })).toBe(string) } else { expect(safeGetParamValue(string, { param: schema })).toMatchObject(parsed) expect(safeSetParamValue(parsed, { param: schema })).toBe(string) } }) ================================================ FILE: src/services/valibot.ts ================================================ import { Param, ParamGetSet } from '@/types/paramTypes' import { isRecord } from '@/utilities/guards' import { isPromise } from '@/utilities/promises' import { StandardSchemaV1 } from '@standard-schema/spec' export interface ValibotSchemaLike extends StandardSchemaV1 { type: string, } // inferring the return type is preferred for this function // eslint-disable-next-line @typescript-eslint/explicit-function-return-type function parse(schema: ValibotSchemaLike, value: unknown) { const result = schema['~standard'].validate(value) if (isPromise(result)) { throw new Error('Promise schemas are not supported') } if (result.issues) { throw new Error('Validation failed') } return result.value } function isValibotSchemaLike(param: Param): param is ValibotSchemaLike { return isRecord(param) && 'type' in param && typeof param.type === 'string' && '~standard' in param && isRecord(param['~standard']) && 'vendor' in param['~standard'] && param['~standard'].vendor === 'valibot' } export function isValibotParam(value: Param): value is ValibotSchemaLike { return isValibotSchemaLike(value) } export function createValibotParam(schema: ValibotSchemaLike): ParamGetSet { return { get: (value, { invalid }) => { try { return parseValibotValue(value, schema) as T } catch { throw invalid() } }, set: (value, { invalid }) => { try { return stringifyValibotValue(value, schema) } catch { throw invalid() } }, } } const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ function reviver(_key: string, value: any): any { if (typeof value === 'string' && isoDateRegex.test(value)) { const date = new Date(value) if (isNaN(date.getTime())) { return value } return date } return value } function tryAll(fns: (() => T)[]): T { for (const fn of fns) { try { return fn() } catch { continue } } throw new Error('All functions failed') } // Sorts string schemas last function sortValibotSchemas(schemaA: ValibotSchemaLike, schemaB: ValibotSchemaLike): number { return schemaA.type === 'string' ? 1 : schemaB.type === 'string' ? -1 : 0 } function parseValibotValue(value: string, schema: ValibotSchemaLike): unknown { if (schema.type === 'boolean') { return parse(schema, Boolean(value)) } if (schema.type === 'date') { return parse(schema, new Date(value)) } if (schema.type === 'number') { return parse(schema, Number(value)) } if (schema.type === 'literal') { return tryAll([ () => parse(schema, Number(value)), () => parse(schema, Boolean(value)), () => parse(schema, value), ]) } if (schema.type === 'object') { return parse(schema, JSON.parse(value, reviver)) } if (schema.type === 'enum') { return tryAll([ () => parse(schema, Number(value)), () => parse(schema, Boolean(value)), () => parse(schema, value), ]) } if (schema.type === 'array') { return parse(schema, JSON.parse(value, reviver)) } if (schema.type === 'tuple') { return parse(schema, JSON.parse(value, reviver)) } if (schema.type === 'union' && 'options' in schema) { const schemas = (schema.options as ValibotSchemaLike[]) .sort(sortValibotSchemas) .map((schema) => () => parseValibotValue(value, schema)) return tryAll(schemas) } if (schema.type === 'variant' && 'options' in schema) { const schemas = (schema.options as ValibotSchemaLike[]) .sort(sortValibotSchemas) .map((schema) => () => parseValibotValue(value, schema)) return tryAll(schemas) } if (schema.type === 'record') { return parse(schema, JSON.parse(value, reviver)) } if (schema.type === 'map') { return parse(schema, new Map(JSON.parse(value, reviver))) } if (schema.type === 'set') { return parse(schema, new Set(JSON.parse(value, reviver))) } if (schema.type === 'intersection') { throw new Error('Intersection schemas are not supported') } if (schema.type === 'promise') { throw new Error('Promise schemas are not supported') } if (schema.type === 'function') { throw new Error('Function schemas are not supported') } return parse(schema, value) } function stringifyValibotValue(value: unknown, schema: ValibotSchemaLike): string { if (schema.type === 'string') { return parse(schema, value).toString() } if (schema.type === 'boolean') { return parse(schema, value).toString() } if (schema.type === 'date') { return parse(schema, value).toISOString() } if (schema.type === 'number') { return parse(schema, Number(value)).toString() } if (schema.type === 'literal') { return parse(schema, value).toString() } if (schema.type === 'object') { return JSON.stringify(parse(schema, value)) } if (schema.type === 'enum') { return parse(schema, value).toString() } if (schema.type === 'nativeEnum') { return parse(schema, value).toString() } if (schema.type === 'array') { return JSON.stringify(parse(schema, value)) } if (schema.type === 'tuple') { return JSON.stringify(parse(schema, value)) } if (schema.type === 'union' && 'options' in schema) { const schemas = (schema.options as ValibotSchemaLike[]) .sort(sortValibotSchemas) .map((schema) => () => stringifyValibotValue(value, schema)) return tryAll(schemas) } if (schema.type === 'variant' && 'options' in schema) { const schemas = (schema.options as ValibotSchemaLike[]) .sort(sortValibotSchemas) .map((schema) => () => stringifyValibotValue(value, schema)) return tryAll(schemas) } if (schema.type === 'record') { return JSON.stringify(parse(schema, value)) } if (schema.type === 'map') { const parsed = parse(schema, value) return JSON.stringify(Array.from(parsed.entries())) } if (schema.type === 'set') { const parsed = parse(schema, value) return JSON.stringify(Array.from(parsed.values())) } if (schema.type === 'intersection') { throw new Error('Intersection schemas are not supported') } if (schema.type === 'promise') { throw new Error('Promise schemas are not supported') } if (schema.type === 'function') { throw new Error('Function schemas are not supported') } return JSON.stringify(parse(schema, value)) } ================================================ FILE: src/services/withDefault.ts ================================================ import { createParam } from '@/services/createParam' import { ExtractParamType, isParamGetSet } from '@/types/params' import { Param, ParamGetSet } from '@/types/paramTypes' export type ParamWithDefault = Required>> export function isParamWithDefault(param: Param): param is ParamWithDefault { return isParamGetSet(param) && param.defaultValue !== undefined } export function withDefault(param: TParam, defaultValue: ExtractParamType): ParamWithDefault { return createParam(param, defaultValue) } ================================================ FILE: src/services/withParams.spec-d.ts ================================================ import { expectTypeOf, test, describe } from 'vitest' import { ToUrlQueryPart, ToUrlPart, UrlPart, withParams } from '@/services/withParams' import { ParamWithDefault } from './withDefault' test('given a string without params, expects no params', () => { const source = withParams('/something-without-params', {}) type Source = typeof source type Expect = UrlPart<{}> expectTypeOf().toEqualTypeOf() }) test('given a string with required params NOT assigned, uses string constructor', () => { const source = withParams('/something-with-[param]', {}) type Source = typeof source type Expect = UrlPart<{ param: { param: StringConstructor, isOptional: false, isGreedy: false } }> expectTypeOf().toEqualTypeOf() }) test('given a string with required params assigned, uses passed in Type', () => { const source = withParams('/something-with-[param]', { param: Boolean }) type Source = typeof source type Expect = UrlPart<{ param: { param: BooleanConstructor, isOptional: false, isGreedy: false } }> expectTypeOf().toEqualTypeOf() }) describe('ToUrlPart', () => { test('given a string, returns ToUrlPart with that string and empty params', () => { type Source = ToUrlPart<'test'> type Expect = UrlPart<{}> expectTypeOf().toEqualTypeOf() }) test('given undefined, returns ToUrlPart with empty string', () => { type Source = ToUrlPart type Expect = UrlPart<{}> expectTypeOf().toEqualTypeOf() }) test('given a ToUrlPart type, returns the same type', () => { type Source = ToUrlPart> type Expect = UrlPart<{ foo: { param: NumberConstructor, isOptional: false, isGreedy: false } }> expectTypeOf().toEqualTypeOf() }) }) describe('ToUrlQueryPart', () => { test('given a string, returns UrlPart with that string', () => { type Source = ToUrlQueryPart<'foo=bar'> type Expect = UrlPart<{}> expectTypeOf().toEqualTypeOf() }) test('given undefined, returns UrlPart with empty string', () => { type Source = ToUrlQueryPart type Expect = UrlPart<{}> expectTypeOf().toEqualTypeOf() }) test('given a UrlPart type, returns the same type', () => { const query = withParams('foo=[foo]', { foo: Number }) type Source = ToUrlQueryPart type Expect = UrlPart<{ foo: { param: NumberConstructor, isOptional: false, isGreedy: false } }> expectTypeOf().toEqualTypeOf() }) test('given a record with string values, returns record of UrlPart', () => { type Source = ToUrlQueryPart<{ foo: 'bar', baz: 'qux' }> type Expect = UrlPart<{}> expectTypeOf().toEqualTypeOf() }) test('given a record with Param values, returns record of parameterized UrlPart', () => { type Source = ToUrlQueryPart<{ foo: NumberConstructor, baz: BooleanConstructor, zoo: '14' }> type Expect = UrlPart<{ foo: { param: NumberConstructor, isOptional: false, isGreedy: false }, baz: { param: BooleanConstructor, isOptional: false, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) test('given a record with Param values and optional key, returns record of parameterized UrlPart', () => { type Source = ToUrlQueryPart<{ 'foo': NumberConstructor, '?baz': BooleanConstructor, '?zoo': '14' }> type Expect = UrlPart<{ foo: { param: NumberConstructor, isOptional: false, isGreedy: false }, baz: { param: BooleanConstructor, isOptional: true, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) test('given a record with Param values with a param that has a default value, returns record of parameterized UrlPart', () => { type Source = ToUrlQueryPart<{ foo: NumberConstructor, baz: ParamWithDefault }> type Expect = { foo: { isOptional: false, isGreedy: false }, baz: { isOptional: true, isGreedy: false }, } expectTypeOf().toMatchObjectType() }) test('given an array with string tuples, each element maps to UrlPart', () => { type Source = ToUrlQueryPart<[['foo', 'bar'], ['baz', 'qux']]> type Expect = UrlPart<{}> expectTypeOf().toEqualTypeOf() }) test('given an array with Param tuples, each element maps to parameterized UrlPart', () => { type Source = ToUrlQueryPart<[['foo', NumberConstructor], ['baz', BooleanConstructor], ['zoo', '14']]> type Expect = UrlPart<{ foo: { param: NumberConstructor, isOptional: false, isGreedy: false }, baz: { param: BooleanConstructor, isOptional: false, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) test('given an array with Param tuples and optional key, each element maps to parameterized UrlPart', () => { type Source = ToUrlQueryPart<[['foo', NumberConstructor], ['?baz', BooleanConstructor], ['?zoo', '14']]> type Expect = UrlPart<{ foo: { param: NumberConstructor, isOptional: false, isGreedy: false }, baz: { param: BooleanConstructor, isOptional: true, isGreedy: false }, }> expectTypeOf().toEqualTypeOf() }) test('given an array with Param tuples and Param with default value, each element maps to parameterized UrlPart', () => { type Source = ToUrlQueryPart<[['foo', NumberConstructor], ['baz', ParamWithDefault]]> type Expect = { foo: { isOptional: false, isGreedy: false }, baz: { isOptional: true, isGreedy: false }, } expectTypeOf().toMatchObjectType() }) }) ================================================ FILE: src/services/withParams.spec.ts ================================================ import { expect, test, describe } from 'vitest' import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { withDefault } from '@/services/withDefault' import { toUrlQueryPart, toUrlPart, withParams } from '@/services/withParams' test('given value without params, returns empty object', () => { const response = withParams('example-without-params', {}) expect(response.params).toMatchObject({}) }) test('given value with simple params, returns each param name as type String', () => { const response = withParams('[parentId]-[childId]', {}) expect(response.params).toMatchObject({ parentId: { param: String, isOptional: false, isGreedy: false }, childId: { param: String, isOptional: false, isGreedy: false }, }) }) test('given value with optional params, returns each param name as type String', () => { const response = withParams('[?parentId]-[?childId]', {}) expect(JSON.stringify(response.params)).toMatch(JSON.stringify({ parentId: { param: String, isOptional: true, isGreedy: false }, childId: { param: String, isOptional: true, isGreedy: false }, })) }) test('given value with param types, returns each param with corresponding param', () => { const response = withParams('[parentId]-[childId]', { parentId: Boolean, }) expect(response.params).toMatchObject({ parentId: { param: Boolean, isOptional: false, isGreedy: false }, childId: { param: String, isOptional: false, isGreedy: false }, }) }) test('given value with the same param name, throws DuplicateParamsError', () => { const action: () => void = () => withParams('[foo]-[?foo]', { }) expect(action).toThrowError(DuplicateParamsError) }) describe('toUrlPart', () => { test('given a string, returns UrlPart with that value', () => { const response = toUrlPart('foo=bar') expect(response.value).toBe('foo=bar') }) test('given undefined, returns UrlPart with empty value', () => { const response = toUrlPart(undefined) expect(response.value).toBe('') }) test('given a UrlPart object, returns same object', () => { const original = withParams('test', {}) const response = toUrlPart(original) expect(response).toBe(original) }) }) describe('toUrlQueryPart', () => { test('given a string, returns UrlPartWithParams with that value', () => { const response = toUrlQueryPart('foo=bar') expect(response.value).toBe('foo=bar') }) test('given undefined, returns UrlPartWithParams with empty value', () => { const response = toUrlQueryPart(undefined) expect(response.value).toBe('') }) test('given a UrlPartWithParams object, returns same object', () => { const original = withParams('test', {}) const response = toUrlQueryPart(original) expect(response).toBe(original) }) test('given a record with string values, converts to query string', () => { const response = toUrlQueryPart({ foo: 'bar', baz: 'qux' }) expect(response.value).toBe('foo=bar&baz=qux') expect(response.params).toMatchObject({}) }) test('given a record with Param values, converts to parameterized query string', () => { const response = toUrlQueryPart({ foo: Number, baz: Boolean }) expect(response.value).toBe('foo=[foo]&baz=[baz]') expect(response.params).toMatchObject({ foo: { param: Number, isOptional: false, isGreedy: false }, baz: { param: Boolean, isOptional: false, isGreedy: false }, }) }) test('given a record with Param values and optional key, converts to parameterized query string', () => { const response = toUrlQueryPart({ 'foo': Number, '?baz': Boolean, '?zoo': 'zoo' }) expect(response.value).toBe('foo=[foo]&baz=[?baz]&?zoo=zoo') expect(response.params).toMatchObject({ foo: { param: Number, isOptional: false, isGreedy: false }, baz: { param: Boolean, isOptional: true, isGreedy: false }, }) }) test('given a record with Param values and Param with default value, converts to parameterized query string', () => { const response = toUrlQueryPart({ foo: Number, baz: withDefault(Boolean, true) }) expect(response.value).toBe('foo=[foo]&baz=[baz]') expect(response.params).toMatchObject({ foo: { isOptional: false, isGreedy: false }, baz: { isOptional: true, isGreedy: false }, }) }) test('given an array with string values, converts to query string', () => { const response = toUrlQueryPart([['foo', 'bar'], ['baz', 'qux']]) expect(response.value).toBe('foo=bar&baz=qux') expect(response.params).toMatchObject({}) }) test('given an array with Param values, converts to parameterized query string', () => { const response = toUrlQueryPart([['foo', Number], ['baz', Boolean], ['zoo', 'zoo']]) expect(response.value).toBe('foo=[foo]&baz=[baz]&zoo=zoo') expect(response.params).toMatchObject({ foo: { param: Number, isOptional: false, isGreedy: false }, baz: { param: Boolean, isOptional: false, isGreedy: false }, }) }) test('given an array with Param values and optional key, converts to parameterized query string', () => { const response = toUrlQueryPart([['foo', Number], ['?baz', Boolean], ['?zoo', 'zoo']]) expect(response.value).toBe('foo=[foo]&baz=[?baz]&?zoo=zoo') expect(response.params).toMatchObject({ foo: { param: Number, isOptional: false, isGreedy: false }, baz: { param: Boolean, isOptional: true, isGreedy: false }, }) }) test('given an array with Param values and Param with default value, converts to parameterized query string', () => { const response = toUrlQueryPart([['foo', Number], ['baz', withDefault(Boolean, true)]]) expect(response.value).toBe('foo=[foo]&baz=[baz]') expect(response.params).toMatchObject({ foo: { isOptional: false, isGreedy: false }, baz: { isOptional: true, isGreedy: false }, }) }) }) ================================================ FILE: src/services/withParams.ts ================================================ import { getParamsForString } from '@/services/getParamsForString' import { getParamName } from '@/services/routeRegex' import { ExtractParamName, isLiteralParam, ParamEnd, ParamIsGreedy, ParamIsOptionalOrHasDefault, ParamStart } from '@/types/params' import { Param } from '@/types/paramTypes' import { Identity } from '@/types/utilities' import { isRecord } from '@/utilities/guards' import { MakeOptional } from '@/utilities/makeOptional' type WithParamsParamsInput< TValue extends string > = TValue extends `${string}${ParamStart}${infer TParam}${ParamEnd}${infer Rest}` ? Record, Param | undefined> & WithParamsParamsInput : {} type WithParamsParamsOutput< TValue extends string, TParams extends Record = Record > = TValue extends `${string}${ParamStart}${infer TParam}${ParamEnd}${infer Rest}` ? ExtractParamName extends keyof TParams ? TParams[ExtractParamName] extends Param ? Record, { param: TParams[ExtractParamName], isOptional: ParamIsOptionalOrHasDefault]>, isGreedy: ParamIsGreedy }> & WithParamsParamsOutput : Record, { param: StringConstructor, isOptional: ParamIsOptionalOrHasDefault]>, isGreedy: ParamIsGreedy }> & WithParamsParamsOutput : Record, { param: StringConstructor, isOptional: ParamIsOptionalOrHasDefault]>, isGreedy: ParamIsGreedy }> & WithParamsParamsOutput : {} const UrlPartsWithParamsSymbol = Symbol('UrlPartsWithParams') export type UrlParam = { param: TParam, isOptional: boolean, isGreedy: boolean } export type RequiredUrlParam = { param: TParam, isOptional: false, isGreedy: false } export type OptionalUrlParam = { param: TParam, isOptional: true, isGreedy: false } export type UrlParams = Record export type UrlPart = { value: string, params: TParams, [UrlPartsWithParamsSymbol]: true, } export type ToUrlPart = T extends string ? UrlPart> : T extends undefined ? UrlPart<{}> : unknown extends T ? UrlPart<{}> : T function isUrlPart(maybeUrlPartsWithParams: unknown): maybeUrlPartsWithParams is UrlPart { return isRecord(maybeUrlPartsWithParams) && maybeUrlPartsWithParams[UrlPartsWithParamsSymbol] === true } export function toUrlPart(value: T): ToUrlPart export function toUrlPart(value: T): UrlPart { if (value === undefined) { return withParams() } if (isUrlPart(value)) { return value } return withParams(value, {}) } export function withParams< const TValue extends string, const TParams extends MakeOptional> >(value: TValue, params: TParams): UrlPart> export function withParams(): UrlPart<{}> export function withParams(value?: string, params?: Record): UrlPart { return { value: value ?? '', params: getParamsForString(value, params), [UrlPartsWithParamsSymbol]: true, } } /** * Type for query source that can be converted to a UrlPart object. * Supports * { query: 'foo=bar' } * { query: 'foo=[bar]' } * { query: { foo: 'bar' } } * { query: [['foo', 'bar']] } * { query: { foo: Param } } * { query: [[ 'foo', Param ]] } */ export type UrlQueryPart = UrlPart | Record | [string, Param][] export type ToUrlQueryPart = T extends string ? UrlPart> : T extends UrlPart ? T : T extends undefined ? UrlPart<{}> : T extends Record ? UrlPart> : T extends [string, string | Param][] ? UrlPart> : UrlPart<{}> type QueryRecordToUrlPart> = { [K in keyof T as T[K] extends string ? never : ExtractParamName]: { param: T[K], isOptional: ParamIsOptionalOrHasDefault, isGreedy: false } } type QueryArrayToUrlPart = T extends [infer First extends [string, string | Param], ...infer Rest extends [string, string | Param][]] ? First extends [string, string] ? {} : First extends [infer TKey extends string, infer TValue extends Param] ? Identity, { param: TValue, isOptional: ParamIsOptionalOrHasDefault, isGreedy: false }> & QueryArrayToUrlPart> : never : {} export function toUrlQueryPart(querySource: T): ToUrlQueryPart export function toUrlQueryPart(querySource: UrlQueryPart): UrlPart { if (typeof querySource === 'string' || typeof querySource === 'undefined' || isUrlPart(querySource)) { return toUrlPart(querySource) } const entries = Array.isArray(querySource) ? querySource : Object.entries(querySource) const source: string[] = [] const params: Record = {} for (const [key, value] of entries) { if (isLiteralParam(value)) { source.push(`${key}=${value}`) } else { const paramKey = `[${key}]` const paramName = getParamName(paramKey) if (paramName) { params[paramName] = value } source.push(`${paramName}=${paramKey}`) } } return withParams(source.join('&'), params) } ================================================ FILE: src/services/zod.spec-d.ts ================================================ import { expectTypeOf, test } from 'vitest' import { withParams } from './withParams' import { z } from 'zod' import { ExtractParamType } from '@/types/params' test('withParams accepts zod schemas', () => { const schema = z.string() const { params } = withParams('/[foo]', { foo: schema }) expectTypeOf(params.foo).toEqualTypeOf<{ param: typeof schema, isOptional: false, isGreedy: false }>() }) test('ExtractParamType returns the correct type for zod params', () => { const param = z.boolean() type Input = ExtractParamType expectTypeOf().toEqualTypeOf() }) ================================================ FILE: src/services/zod.spec.ts ================================================ import { safeGetParamValue, safeSetParamValue } from './params' import { z } from 'zod' import { test, expect } from 'vitest' import { initZod } from './zod' const discriminatedUnion = z.discriminatedUnion('type', [ z.object({ type: z.literal('one'), value: z.string() }), z.object({ type: z.literal('two'), value: z.number() }), ]) enum Fruits { Apple = '0', Banana = '1' } test.each([ { schema: z.literal('foo'), string: 'foo', parsed: 'foo' }, { schema: z.literal(1), string: '1', parsed: 1 }, { schema: z.literal(true), string: 'true', parsed: true }, { schema: z.string(), string: 'foo', parsed: 'foo' }, { schema: z.number(), string: '1', parsed: 1 }, { schema: z.boolean(), string: 'true', parsed: true }, { schema: z.iso.datetime(), string: '2022-01-12T00:00:00.000Z', parsed: '2022-01-12T00:00:00.000Z' }, { schema: z.iso.date(), string: '2022-01-12', parsed: '2022-01-12' }, { schema: z.iso.time(), string: '09:52:31', parsed: '09:52:31' }, { schema: z.date(), string: '2022-01-12T00:00:00.000Z', parsed: new Date('2022-01-12T00:00:00.000Z') }, { schema: z.object({ foo: z.string() }), string: '{"foo":"bar"}', parsed: { foo: 'bar' } }, { schema: z.object({ foo: z.string().nullable() }), string: '{"foo":null}', parsed: { foo: null } }, { schema: z.object({ foo: z.string().optional() }), string: '{}', parsed: {} }, { schema: z.enum(['foo', 'bar']), string: 'foo', parsed: 'foo' }, { schema: z.enum(Fruits), string: '0', parsed: Fruits.Apple }, { schema: z.string().array(), string: '["foo","bar"]', parsed: ['foo', 'bar'] }, { schema: z.ipv4(), string: '192.168.1.1', parsed: '192.168.1.1' }, { schema: z.ipv6(), string: '2001:db8::1', parsed: '2001:db8::1' }, { schema: z.cidrv4(), string: '192.168.0.0/24', parsed: '192.168.0.0/24' }, { schema: z.cidrv6(), string: '2001:db8::/64', parsed: '2001:db8::/64' }, { schema: z.url(), string: 'https://example.com', parsed: 'https://example.com' }, { schema: z.email(), string: 'test@example.com', parsed: 'test@example.com' }, { schema: z.uuid(), string: '123e4567-e89b-12d3-a456-426614174000', parsed: '123e4567-e89b-12d3-a456-426614174000' }, { schema: z.base64(), string: 'SGVsbG8gV29ybGQ=', parsed: 'SGVsbG8gV29ybGQ=' }, { schema: z.cuid(), string: 'ckopqwooh000001la8h2e3xb6', parsed: 'ckopqwooh000001la8h2e3xb6' }, { schema: z.cuid2(), string: 'tz4a98xxat96iws9zmbrgj3a', parsed: 'tz4a98xxat96iws9zmbrgj3a' }, { schema: z.ulid(), string: '01ARZ3NDEKTSV4RRFFQ69G5FAV', parsed: '01ARZ3NDEKTSV4RRFFQ69G5FAV' }, { schema: z.jwt(), string: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', parsed: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U' }, { schema: z.bigint(), string: '123', parsed: 123n }, { schema: z.nan(), string: 'NaN', parsed: NaN }, { schema: z.tuple([z.string(), z.number()]), string: '["foo",1]', parsed: ['foo', 1] }, { schema: z.union([z.string(), z.number()]), string: 'foo', parsed: 'foo' }, { schema: z.union([z.string(), z.number()]), string: '1', parsed: 1 }, { schema: z.union([z.string(), z.object({ foo: z.string() })]), string: '{"foo":"bar"}', parsed: { foo: 'bar' } }, { schema: discriminatedUnion, string: '{"type":"one","value":"foo"}', parsed: { type: 'one', value: 'foo' } }, { schema: discriminatedUnion, string: '{"type":"two","value":1}', parsed: { type: 'two', value: 1 } }, { schema: z.record(z.string(), z.object({ foo: z.string() })), string: '{"one":{"foo":"bar"}}', parsed: { one: { foo: 'bar' } } }, { schema: z.map(z.string(), z.number()), string: '[["one",1]]', parsed: new Map([['one', 1]]) }, { schema: z.set(z.number()), string: '[1,2,3]', parsed: new Set([1, 2, 3]) }, ])('given $schema, returns $parsed for $string', async ({ schema, string, parsed }) => { await initZod() if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean' || typeof parsed === 'bigint') { expect(safeGetParamValue(string, { param: schema })).toBe(parsed) expect(safeSetParamValue(parsed, { param: schema })).toBe(string) } else { expect(safeGetParamValue(string, { param: schema })).toMatchObject(parsed) expect(safeSetParamValue(parsed, { param: schema })).toBe(string) } }) test.each([ { schema: z.intersection(z.object({ foo: z.string() }), z.object({ bar: z.number() })), type: 'Intersection' }, { schema: z.promise(z.string()), type: 'Promise' }, ])('$type schemas are not supported', async ({ schema }) => { await initZod() expect(safeGetParamValue('test', { param: schema })).toBeUndefined() expect(safeSetParamValue('test', { param: schema })).toBeUndefined() }) ================================================ FILE: src/services/zod.ts ================================================ import { Param, ParamGetSet } from '@/types/paramTypes' import { Routes } from '@/types/route' import { isUrl } from '@/types/url' import { isRecord } from '@/utilities/guards' import { StandardSchemaV1 } from '@standard-schema/spec' import { type ZodType } from 'zod' export interface ZodSchemaLike extends StandardSchemaV1 { parse: (input: any) => any, } let zod: ZodSchemas | null = null // inferring the return type is preferred for this function // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async function getZodInstances() { const { ZodType, ZodString, ZodIPv4, ZodIPv6, ZodCIDRv4, ZodCIDRv6, ZodURL, ZodEmail, ZodUUID, ZodBase64, ZodCUID, ZodCUID2, ZodULID, ZodJWT, ZodBigInt, ZodNaN, ZodBoolean, ZodDate, ZodISODateTime, ZodISODate, ZodISOTime, ZodNumber, ZodLiteral, ZodObject, ZodEnum, ZodArray, ZodTuple, ZodUnion, ZodDiscriminatedUnion, ZodRecord, ZodMap, ZodSet, ZodIntersection, ZodPromise, } = await import('zod') return { ZodType, ZodString, ZodIPv4, ZodIPv6, ZodCIDRv4, ZodCIDRv6, ZodURL, ZodEmail, ZodUUID, ZodBase64, ZodCUID, ZodCUID2, ZodULID, ZodJWT, ZodBigInt, ZodNaN, ZodBoolean, ZodDate, ZodISODateTime, ZodISODate, ZodISOTime, ZodNumber, ZodLiteral, ZodObject, ZodEnum, ZodArray, ZodTuple, ZodUnion, ZodDiscriminatedUnion, ZodRecord, ZodMap, ZodSet, ZodIntersection, ZodPromise, } } type ZodSchemas = Awaited> export function zodParamsDetected(routes: Routes): boolean { return Object.values(routes).some((route) => { if (!isUrl(route)) { return false } return Object.values(route.schema.host.params).some(({ param }) => isZodSchemaLike(param)) || Object.values(route.schema.path.params).some(({ param }) => isZodSchemaLike(param)) || Object.values(route.schema.query.params).some(({ param }) => isZodSchemaLike(param)) }) } function isZodSchemaLike(param: Param): param is ZodSchemaLike { return isRecord(param) && 'parse' in param && typeof param.parse === 'function' && '~standard' in param && isRecord(param['~standard']) && 'vendor' in param['~standard'] && param['~standard'].vendor === 'zod' } export async function initZod(): Promise { try { zod = await getZodInstances() } catch { throw new Error('Failed to initialize Zod') } } export function isZodParam(value: unknown): value is ZodType { if (!zod) { return false } return value instanceof zod.ZodType } export function createZodParam(schema: ZodType): ParamGetSet { return { get: (value, { invalid }) => { try { return parseZodValue(value, schema) as T } catch { throw invalid() } }, set: (value, { invalid }) => { try { return stringifyZodValue(value, schema) } catch { throw invalid() } }, } } const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ function reviver(_key: string, value: any): any { if (typeof value === 'string' && isoDateRegex.test(value)) { const date = new Date(value) if (isNaN(date.getTime())) { return value } return date } return value } function tryAll(fns: (() => T)[]): T { for (const fn of fns) { try { return fn() } catch { continue } } throw new Error('All functions failed') } // Sorts string schemas last function sortZodSchemas(schemaA: ZodType, schemaB: ZodType): number { return zod?.ZodString && schemaA instanceof zod.ZodString ? 1 : zod?.ZodString && schemaB instanceof zod.ZodString ? -1 : 0 } function parseZodValue(value: string, schema: ZodType): unknown { if (!zod) { throw new Error('Zod is not initialized') } if (schema instanceof zod.ZodString) { return schema.parse(value) } if (schema instanceof zod.ZodBoolean) { return schema.parse(Boolean(value)) } if (schema instanceof zod.ZodDate) { return schema.parse(new Date(value)) } if (schema instanceof zod.ZodNumber) { return schema.parse(Number(value)) } if (schema instanceof zod.ZodBigInt) { return schema.parse(BigInt(value)) } if (schema instanceof zod.ZodNaN) { return schema.parse(Number(value)) } if (schema instanceof zod.ZodLiteral) { return tryAll([ () => schema.parse(Number(value)), () => schema.parse(Boolean(value)), () => schema.parse(value), ]) } if (schema instanceof zod.ZodObject) { return schema.parse(JSON.parse(value, reviver)) } if (schema instanceof zod.ZodEnum) { return schema.parse(value) } if (schema instanceof zod.ZodArray) { return schema.parse(JSON.parse(value, reviver)) } if (schema instanceof zod.ZodTuple) { return schema.parse(JSON.parse(value, reviver)) } if (schema instanceof zod.ZodUnion) { const schemas = Array .from(schema.def.options as ZodType[]) .sort(sortZodSchemas) .map((schema: ZodType) => () => parseZodValue(value, schema)) return tryAll(schemas) } if (schema instanceof zod.ZodDiscriminatedUnion) { const schemas = Array .from(schema.options as ZodType[]) .sort(sortZodSchemas) .map((schema: ZodType) => () => parseZodValue(value, schema)) return tryAll(schemas) } if (schema instanceof zod.ZodRecord) { return schema.parse(JSON.parse(value, reviver)) } if (schema instanceof zod.ZodMap) { return schema.parse(new Map(JSON.parse(value, reviver))) } if (schema instanceof zod.ZodSet) { return schema.parse(new Set(JSON.parse(value, reviver))) } if (schema instanceof zod.ZodIntersection) { throw new Error('Intersection schemas are not supported') } if (schema instanceof zod.ZodPromise) { throw new Error('Promise schemas are not supported') } return schema.parse(value) } function stringifyZodValue(value: unknown, schema: ZodType): string { if (!zod) { throw new Error('Zod is not initialized') } if (schema instanceof zod.ZodString) { return schema.parse(value) } if (schema instanceof zod.ZodISODateTime) { return schema.parse(value) } if (schema instanceof zod.ZodISODate) { return schema.parse(value) } if (schema instanceof zod.ZodISOTime) { return schema.parse(value) } if (schema instanceof zod.ZodIPv4) { return schema.parse(value) } if (schema instanceof zod.ZodIPv6) { return schema.parse(value) } if (schema instanceof zod.ZodCIDRv4) { return schema.parse(value) } if (schema instanceof zod.ZodCIDRv6) { return schema.parse(value) } if (schema instanceof zod.ZodURL) { return schema.parse(value) } if (schema instanceof zod.ZodEmail) { return schema.parse(value) } if (schema instanceof zod.ZodUUID) { return schema.parse(value) } if (schema instanceof zod.ZodBase64) { return schema.parse(value) } if (schema instanceof zod.ZodCUID) { return schema.parse(value) } if (schema instanceof zod.ZodCUID2) { return schema.parse(value) } if (schema instanceof zod.ZodULID) { return schema.parse(value) } if (schema instanceof zod.ZodJWT) { return schema.parse(value) } if (schema instanceof zod.ZodBoolean) { return schema.parse(value).toString() } if (schema instanceof zod.ZodDate) { return schema.parse(value).toISOString() } if (schema instanceof zod.ZodNumber) { return schema.parse(Number(value)).toString() } if (schema instanceof zod.ZodBigInt) { return schema.parse(BigInt(String(value))).toString() } if (schema instanceof zod.ZodNaN) { return schema.parse(value).toString() } if (schema instanceof zod.ZodLiteral) { const parsed = schema.parse(value) return parsed != null ? parsed.toString() : String(parsed) } if (schema instanceof zod.ZodObject) { return JSON.stringify(schema.parse(value)) } if (schema instanceof zod.ZodEnum) { const parsed = schema.parse(value) return typeof parsed === 'string' ? parsed : String(parsed) } if (schema instanceof zod.ZodArray) { return JSON.stringify(schema.parse(value)) } if (schema instanceof zod.ZodTuple) { return JSON.stringify(schema.parse(value)) } if (schema instanceof zod.ZodUnion) { const schemas = Array .from(schema.def.options as ZodType[]) .sort(sortZodSchemas) .map((schema: ZodType) => () => stringifyZodValue(value, schema)) return tryAll(schemas) } if (schema instanceof zod.ZodDiscriminatedUnion) { const schemas = Array .from(schema.options as ZodType[]) .sort(sortZodSchemas) .map((schema: ZodType) => () => stringifyZodValue(value, schema)) return tryAll(schemas) } if (schema instanceof zod.ZodRecord) { return JSON.stringify(schema.parse(value)) } if (schema instanceof zod.ZodMap) { const parsed = schema.parse(value) return JSON.stringify(Array.from(parsed.entries())) } if (schema instanceof zod.ZodSet) { const parsed = schema.parse(value) return JSON.stringify(Array.from(parsed.values())) } if (schema instanceof zod.ZodIntersection) { throw new Error('Intersection schemas are not supported') } if (schema instanceof zod.ZodPromise) { throw new Error('Promise schemas are not supported') } return JSON.stringify(schema.parse(value)) } ================================================ FILE: src/tests/hooks.spec.ts ================================================ import echo from '@/components/echo' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { component } from '@/utilities' import { expect, test, vi } from 'vitest' import { createApp, inject } from 'vue' test('hooks are run with the correct context', async () => { const beforeRouteHook = vi.fn() const afterRouteHook = vi.fn() const beforeGlobalHook = vi.fn() const afterGlobalHook = vi.fn() const route = createRoute({ path: '/', name: 'route', component: echo, }, () => ({ value: 'hello' })) route.onBeforeRouteEnter(() => { const value = inject('global') beforeRouteHook(value) }) route.onAfterRouteEnter(() => { const value = inject('global') afterRouteHook(value) }) const router = createRouter([route], { initialUrl: '/', }) router.onBeforeRouteEnter(() => { const value = inject('global') beforeGlobalHook(value) }) router.onAfterRouteEnter(() => { const value = inject('global') afterGlobalHook(value) }) const app = createApp(component) app.provide('global', 'hello world') app.use(router) await router.start() expect(beforeRouteHook).toHaveBeenCalledWith('hello world') expect(afterRouteHook).toHaveBeenCalledWith('hello world') expect(beforeGlobalHook).toHaveBeenCalledWith('hello world') expect(afterGlobalHook).toHaveBeenCalledWith('hello world') }) ================================================ FILE: src/tests/routeProps.browser.spec.ts ================================================ /* eslint-disable vue/one-component-per-file */ import { vi, test, expect } from 'vitest' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { defineComponent, h } from 'vue' import { mount } from '@vue/test-utils' import { component } from '@/utilities/testHelpers' import { RouterView } from '@/main' import echo from '@/components/echo' test('components are not remounted when props change', async () => { const setupParent = vi.fn() const setupChild = vi.fn() const routeA = createRoute({ name: 'routeA', path: '/routeA/[parentParam]', component: defineComponent({ props: { value: { type: String, required: true, }, }, setup: setupParent, render(props: { value: string }) { return h('div', {}, [h(echo, { value: props.value }), h(RouterView)]) }, }), }, (route) => { return { value: route.params.parentParam, } }) const routeAChild = createRoute({ parent: routeA, name: 'routeA.child', path: '/childA/[childParam]', component: defineComponent({ setup: setupChild, render(props: { value: string }) { return h(echo, { value: props.value }) }, }), }, (route) => { return { value: route.params.childParam, } }) const routeB = createRoute({ name: 'routeB', path: '/routeB', component, }) const router = createRouter([routeA, routeAChild, routeB], { initialUrl: '/routeA/bar', }) const root = { template: '', } const wrapper = mount(root, { global: { plugins: [router], }, }) await router.start() // the initial render should call the parent setup function expect(setupParent).toHaveBeenCalledTimes(1) // validate that the initial route was rendered expect(wrapper.text()).toBe('bar') // updating the current route should not remount await router.route.update({ parentParam: 'foo' }) expect(setupParent).toHaveBeenCalledTimes(1) await router.route.update({ parentParam: 'bar' }) // navigating away and back should remount await router.push('routeB') await router.push('routeA', { parentParam: 'foo' }) expect(setupParent).toHaveBeenCalledTimes(2) // navigating to the same route with different props should not remount await router.push('routeA', { parentParam: 'bar' }) expect(setupParent).toHaveBeenCalledTimes(2) // navigating to a child route should not remount await router.push('routeA.child', { parentParam: 'foo', childParam: 'foo' }) expect(setupParent).toHaveBeenCalledTimes(2) expect(setupChild).toHaveBeenCalledTimes(1) // updating parent param should not remount parent or child await router.route.update({ parentParam: 'bar' }) expect(setupParent).toHaveBeenCalledTimes(2) expect(setupChild).toHaveBeenCalledTimes(1) // validate that the components were rendered expect(wrapper.text()).toBe('barfoo') }) ================================================ FILE: src/tests/routeProps.spec.ts ================================================ import { expect, test, vi } from 'vitest' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' import { createApp, inject } from 'vue' import { component } from '@/utilities/testHelpers' test('props are called each time the route is matched', async () => { const props = vi.fn() const route = createRoute({ name: 'test', path: '/[param]', }, props) const router = createRouter([route], { initialUrl: '/', }) await router.start() await router.push('test', { param: 'foo' }) expect(props).toHaveBeenCalledTimes(1) await router.push('test', { param: 'bar' }) expect(props).toHaveBeenCalledTimes(2) await router.push('test', { param: 'foo' }) expect(props).toHaveBeenCalledTimes(3) }) test('props are called with the correct context', async () => { const props = vi.fn() const route = createRoute({ name: 'route', path: '/', }, () => { const value = inject('global') props(value) return {} }) const router = createRouter([route], { initialUrl: '/', }) const app = createApp(component) app.provide('global', 'hello world') app.use(router) await router.start() expect(props).toHaveBeenCalledWith('hello world') }) ================================================ FILE: src/types/callbackContext.ts ================================================ import { RouterPush } from './routerPush' export type CallbackContextSuccess = { status: 'SUCCESS', } export type CallbackContextPush = { status: 'PUSH', to: Parameters, } export type CallbackContextReject = { status: 'REJECT', type: string, } export type CallbackContextAbort = { status: 'ABORT', } ================================================ FILE: src/types/createRouteOptions.ts ================================================ import { Component } from 'vue' import { CombineMeta, combineMeta } from '@/services/combineMeta' import { CombineState, combineState } from '@/services/combineState' import { combineHooks } from '@/types/hooks' import { Param } from '@/types/paramTypes' import { PrefetchConfig } from '@/types/prefetch' import { RouteMeta } from '@/types/register' import { isRoute, Route, RouteInternal } from '@/types/route' import { ResolvedRoute } from './resolved' import { ComponentProps } from '@/services/component' import { PropsCallbackContext } from '@/types/props' import { Identity, MaybePromise } from '@/types/utilities' import { ToMeta } from '@/types/meta' import { ToState } from '@/types/state' import { ToName } from '@/types/name' import { UrlPart, UrlQueryPart } from '@/services/withParams' import { RouteContext, ToRouteContext } from '@/types/routeContext' import { RouterViewProps } from '@/components/routerView' import { ToUrl } from '@/types/url' import { CombineUrl } from '@/services/combineUrl' export type WithHost = { /** * Host part of URL. */ host: THost, } export type WithoutHost = { host?: never, } export type WithParent = { parent: TParent, } export function isWithParent>(options: T): options is T & WithParent { return 'parent' in options && Boolean(options.parent) } export type WithoutParent = { parent?: never, } /** * This type is used to strip the component and components properties from the options object * when creating a Route to simplify and minimize the output type. */ type WithoutComponents = { component: never, components: never } export function isWithComponent>(options: T): options is T & { component: Component } { return 'component' in options && Boolean(options.component) } export function isWithComponentProps>(options: T): options is T & { props: PropsGetter } { return 'props' in options && typeof options.props === 'function' } export function isWithComponents>(options: T): options is T & { components: Record } { return 'components' in options && Boolean(options.components) } export function isWithComponentPropsRecord>(options: T): options is T & { props: RoutePropsRecord } { return 'props' in options && typeof options.props === 'object' } export type CreateRouteOptions< TName extends string | undefined = string | undefined, TMeta extends RouteMeta = RouteMeta > = { /** * Name for route, used to create route keys and in navigation. */ name?: TName, /** * Path part of URL. */ path?: string | UrlPart | undefined, /** * Query (aka search) part of URL. */ query?: string | UrlQueryPart | undefined, /** * Hash part of URL. */ hash?: string | UrlPart | undefined, /** * Represents additional metadata associated with a route, customizable via declaration merging. */ meta?: TMeta, /** * Determines what assets are prefetched when router-link is rendered for this route. Overrides router level prefetch. */ prefetch?: PrefetchConfig, /** * Type params for additional data intended to be stored in history state, all keys will be optional unless a default is provided. */ state?: Record, /** * An optional parent route to nest this route under. */ parent?: Route, /** * An optional component to render when this route is matched. * * @default RouterView */ component?: Component, /** * An object of named components to render using named views */ components?: Record, /** * Related routes and rejections for the route. The context is exposed to the hooks and props callback functions for this route. */ context?: RouteContext[], /** * When true, the route will be hoisted to the top of the route tree. The route will continue to inherit meta, state, hooks, matches, and context from it's parent, but not the "url" properties. */ hoist?: boolean, } export type PropsGetter< TOptions extends CreateRouteOptions = CreateRouteOptions, TComponent extends Component = Component > = (route: ResolvedRoute>, context: PropsCallbackContext, TOptions>) => MaybePromise> export type RouterViewPropsGetter< TOptions extends CreateRouteOptions = CreateRouteOptions > = (route: ResolvedRoute>, context: PropsCallbackContext, TOptions>) => MaybePromise> type ComponentPropsAreOptional< TComponent extends Component > = Partial> extends ComponentProps ? true : false type RoutePropsRecord< TOptions extends CreateRouteOptions = CreateRouteOptions, TComponents extends Record = Record > = { [K in keyof TComponents as ComponentPropsAreOptional extends true ? K : never]?: PropsGetter } & { [K in keyof TComponents as ComponentPropsAreOptional extends false ? K : never]: PropsGetter } export type CreateRouteProps< TOptions extends CreateRouteOptions = CreateRouteOptions > = TOptions['component'] extends Component ? PropsGetter : TOptions['components'] extends Record ? RoutePropsRecord : RouterViewPropsGetter type ToMatch< TOptions extends CreateRouteOptions, TProps > = Omit & { id: string, name: ToName, props: TProps, /** * Represents additional metadata associated with a route. Always present, defaults to empty object. */ meta: ToMeta, } type ToMatches< TOptions extends CreateRouteOptions, TProps extends CreateRouteProps | undefined > = TOptions extends { parent: infer TParent extends Route } ? [...TParent['matches'], ToMatch] : [ToMatch, TProps>] export type ToRoute< TOptions extends CreateRouteOptions, TProps extends CreateRouteProps | undefined = undefined > = CreateRouteOptions extends TOptions ? Route : TOptions extends { parent: infer TParent extends Route } ? Route< ToName, TOptions['hoist'] extends true ? ToUrl : CombineUrl>, CombineMeta, ToMeta>, CombineState, ToState>, ToMatches extends TProps ? undefined : TProps>, [...ToRouteContext, ...ToRouteContext] > : Route< ToName, ToUrl>, ToMeta, ToState, ToMatches extends TProps ? undefined : TProps>, ToRouteContext > export function combineRoutes(parent: Route, child: Route): Route { if (!isRoute(parent) || !isRoute(child)) { throw new Error('combineRoutes called with invalid route arguments') } const route = { ...child, meta: combineMeta(parent.meta, child.meta), state: combineState(parent.state, child.state), hooks: combineHooks(parent, child), matches: [...parent.matches, child.matched], context: [...parent.context, ...child.context], depth: parent.depth + 1, } satisfies Route & RouteInternal return route } ================================================ FILE: src/types/hooks.ts ================================================ import { Hooks } from '@/models/hooks' import { ResolvedRoute, RouterResolvedRouteUnion, ResolvedRouteUnion } from '@/types/resolved' import { MaybePromise } from '@/types/utilities' import { isRoute, Route, Routes } from '@/types/route' import { RouterReject } from '@/types/routerReject' import { RouterPush } from '@/types/routerPush' import { RouterReplace } from '@/types/routerReplace' import { isRejection, Rejection, Rejections } from '@/types/rejection' import { RouteContext, RouteContextToRejection, RouteContextToRoute } from '@/types/routeContext' import { RouterAbort } from '@/types/routerAbort' import { CallbackContextAbort, CallbackContextPush, CallbackContextReject, CallbackContextSuccess } from '@/types/callbackContext' import { RouteUpdate } from '@/types/routeUpdate' export function getHooks(value: Record | undefined | null): Hooks[] { return !!value && (isRoute(value) || isRejection(value)) ? value.hooks : [] } export function combineHooks(parent: Route, child: Route): Hooks[] { return [...getHooks(parent), ...getHooks(child)] } export type InternalRouteHooks< TRoute extends Route = Route, TContext extends RouteContext[] | undefined = undefined > = { /** * Registers a route hook to be called before the route is entered. */ onBeforeRouteEnter: AddBeforeEnterHook<[TRoute] | RouteContextToRoute, RouteContextToRejection, TRoute, Route>, /** * Registers a route hook to be called before the route is left. */ onBeforeRouteLeave: AddBeforeLeaveHook<[TRoute] | RouteContextToRoute, RouteContextToRejection, Route, TRoute>, /** * Registers a route hook to be called before the route is updated. */ onBeforeRouteUpdate: AddBeforeUpdateHook<[TRoute] | RouteContextToRoute, RouteContextToRejection, TRoute, Route>, /** * Registers a route hook to be called after the route is entered. */ onAfterRouteEnter: AddAfterEnterHook<[TRoute] | RouteContextToRoute, RouteContextToRejection, TRoute, Route>, /** * Registers a route hook to be called after the route is left. */ onAfterRouteLeave: AddAfterLeaveHook<[TRoute] | RouteContextToRoute, RouteContextToRejection, Route, TRoute>, /** * Registers a route hook to be called after the route is updated. */ onAfterRouteUpdate: AddAfterUpdateHook<[TRoute] | RouteContextToRoute, RouteContextToRejection, TRoute, Route>, } export type ExternalRouteHooks< TRoute extends Route = Route, TContext extends RouteContext[] | undefined = undefined > = { /** * Registers a route hook to be called before the route is entered. */ onBeforeRouteEnter: AddBeforeEnterHook<[TRoute] | RouteContextToRoute, RouteContextToRejection, TRoute, Route>, } export type RejectionHooks< TRejections extends string = string > = { /** * Registers a route hook to be called when a rejection occurs. */ onRejection: AddRejectionHook, } export type HookTiming = 'global' | 'component' /** * Union type for all component route hooks. */ export type ComponentHook = BeforeEnterHook | BeforeUpdateHook | BeforeLeaveHook | AfterEnterHook | AfterUpdateHook | AfterLeaveHook /** * Registration object for adding a component route hook. */ export type ComponentHookRegistration = { lifecycle: HookLifecycle, hook: ComponentHook, depth: number, } /** * Function to add a component route hook with depth-based condition checking. */ export type AddComponentHook = (registration: ComponentHookRegistration) => HookRemove export type AddGlobalHooks = (hooks: Hooks) => void /** * A function to remove a previously added route hook. */ export type HookRemove = () => void /** * Enumerates the lifecycle events for before route hooks. */ export type BeforeHookLifecycle = 'onBeforeRouteEnter' | 'onBeforeRouteUpdate' | 'onBeforeRouteLeave' /** * Enumerates the lifecycle events for after route hooks. */ export type AfterHookLifecycle = 'onAfterRouteEnter' | 'onAfterRouteUpdate' | 'onAfterRouteLeave' /** * Union type for all route hook lifecycle events. */ export type HookLifecycle = BeforeHookLifecycle | AfterHookLifecycle type AfterHookContext< TRoute extends Route, TRoutes extends Routes, TRejections extends Rejections > = { reject: RouterReject, push: RouterPush, replace: RouterReplace, update: RouteUpdate>, } type BeforeHookContext< TRouteTo extends Route, TRoutes extends Routes, TRejections extends Rejections > = { reject: RouterReject, push: RouterPush, replace: RouterReplace, update: RouteUpdate>, abort: RouterAbort, } export type BeforeEnterHookContext< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = BeforeHookContext & { from: ResolvedRouteUnion | null, } export type BeforeEnterHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (to: ResolvedRouteUnion, context: BeforeEnterHookContext) => MaybePromise export type AddBeforeEnterHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (hook: BeforeEnterHook) => HookRemove export type BeforeUpdateHookContext< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = BeforeHookContext & { from: ResolvedRouteUnion | null, } export type BeforeUpdateHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (to: ResolvedRouteUnion, context: BeforeUpdateHookContext) => MaybePromise export type AddBeforeUpdateHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (hook: BeforeUpdateHook) => HookRemove export type BeforeLeaveHookContext< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = BeforeHookContext & { from: ResolvedRouteUnion, } export type BeforeLeaveHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (to: ResolvedRouteUnion, context: BeforeLeaveHookContext) => MaybePromise export type AddBeforeLeaveHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (hook: BeforeLeaveHook) => HookRemove export type AfterEnterHookContext< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = AfterHookContext & { from: ResolvedRouteUnion | null, } export type AfterEnterHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (to: ResolvedRouteUnion, context: AfterEnterHookContext) => MaybePromise export type AddAfterEnterHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (hook: AfterEnterHook) => HookRemove export type AfterUpdateHookContext< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = AfterHookContext & { from: ResolvedRouteUnion | null, } export type AfterUpdateHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (to: ResolvedRouteUnion, context: AfterUpdateHookContext) => MaybePromise export type AddAfterUpdateHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (hook: AfterUpdateHook) => HookRemove export type AfterLeaveHookContext< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = AfterHookContext & { from: ResolvedRouteUnion, } export type AfterLeaveHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (to: ResolvedRouteUnion, context: AfterLeaveHookContext) => MaybePromise export type AddAfterLeaveHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (hook: AfterLeaveHook) => HookRemove export type BeforeHookResponse = CallbackContextSuccess | CallbackContextPush | CallbackContextReject | CallbackContextAbort export type AfterHookResponse = CallbackContextSuccess | CallbackContextPush | CallbackContextReject export type BeforeHookRunner = ( context: { to: RouterResolvedRouteUnion, from: RouterResolvedRouteUnion | null } ) => Promise export type AfterHookRunner = ( context: { to: RouterResolvedRouteUnion, from: RouterResolvedRouteUnion | null } ) => Promise export type RejectionHookContext< TRoutes extends Routes = Routes, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = { to: ResolvedRouteUnion | null, from: ResolvedRouteUnion | null, } export type RejectionHook< TRejection extends string = string, TRoutes extends Routes = Routes, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (rejection: TRejection, context: RejectionHookContext) => MaybePromise export type AddRejectionHook< TRejections extends string = string, TRoutes extends Routes = Routes, TRouteTo extends Route = TRoutes[number], TRouteFrom extends Route = TRoutes[number] > = (hook: RejectionHook) => HookRemove export type RejectionHookRunner = ( rejection: TRejection, context: { to: RouterResolvedRouteUnion | null, from: RouterResolvedRouteUnion | null } ) => void export type ErrorHookContext< TRoute extends Route = Route, TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = { to: RouterResolvedRouteUnion, from: RouterResolvedRouteUnion | null, source: 'props' | 'hook' | 'component', reject: RouterReject, push: RouterPush, replace: RouterReplace, update: RouteUpdate>, } export type ErrorHook< TRoute extends Route = Route, TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = (error: unknown, context: ErrorHookContext) => void export type AddErrorHook< TRoute extends Route = Route, TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = (hook: ErrorHook) => HookRemove export type ErrorHookRunnerContext = { to: RouterResolvedRouteUnion, from: RouterResolvedRouteUnion | null, source: 'props' | 'hook', } export type ErrorHookRunner = ( error: unknown, context: ErrorHookRunnerContext ) => void ================================================ FILE: src/types/meta.ts ================================================ import { RouteMeta } from './register' type EmptyMeta = Readonly<{}> export type ToMeta = TMeta extends undefined ? EmptyMeta : unknown extends TMeta ? EmptyMeta : TMeta ================================================ FILE: src/types/name.ts ================================================ export type ToName = T extends string ? T : '' export function toName(value: T): ToName export function toName(value: T): string { if (value === undefined) { return '' } return value } ================================================ FILE: src/types/paramTypes.ts ================================================ import { ValibotSchemaLike } from '@/services/valibot' import { ZodSchemaLike } from '@/services/zod' export type ParamExtras = { invalid: (message?: string) => never, } export type ParamGetter = (value: string, extras: ParamExtras) => T export type ParamSetter = (value: T, extras: ParamExtras) => string export type ParamGetSet = { get: ParamGetter, set: ParamSetter, defaultValue?: T, } export type LiteralParam = string | number | boolean export type Param = | ParamGetter | ParamGetSet | RegExp | BooleanConstructor | NumberConstructor | StringConstructor | DateConstructor | JSON | ZodSchemaLike | ValibotSchemaLike | LiteralParam ================================================ FILE: src/types/params.ts ================================================ import { LiteralParam, Param, ParamGetSet, ParamGetter } from '@/types/paramTypes' import { StandardSchemaV1 } from '@standard-schema/spec' export const paramStart = '[' export type ParamStart = typeof paramStart export const paramEnd = ']' export type ParamEnd = typeof paramEnd /** * Determines if a given value is not a constructor for String, Boolean, Date, or Number. * @param value - The value to check. * @returns True if the value is not a constructor function for String, Boolean, Date, or Number. */ function isNotConstructor(value: Param): boolean { return value !== String && value !== Boolean && value !== Number && value !== Date } /** * Type guard to check if a value conforms to the ParamGetter type. * @param value - The value to check. * @returns True if the value is a function that is not a constructor. */ export function isParamGetter(value: Param): value is ParamGetter { return typeof value === 'function' && isNotConstructor(value) } /** * Type guard to check if a value conforms to the ParamGetSet type. * @param value - The value to check. * @returns True if the value is an object with both 'get' and 'set' functions defined. */ export function isParamGetSet(value: Param): value is ParamGetSet { return typeof value === 'object' && 'get' in value && typeof value.get === 'function' && 'set' in value && typeof value.set === 'function' } /** * Type guard to check if a value conforms to the LiteralParam type. * @param value - The value to check. * @returns True if the value is a string, number, or boolean. */ export function isLiteralParam(value: Param): value is LiteralParam { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' } /** * Extracts the parameter name from a string, handling optional parameters denoted by a leading '?'. * @template TParam - The string from which to extract the parameter name. * @returns The extracted parameter name, or never if the parameter string is empty. */ export type ExtractParamName< TParam extends PropertyKey > = TParam extends string ? TParam extends `?${infer Param}` ? Param extends '' ? never : Param extends `${infer Name}*` ? Name : Param : TParam extends '' ? never : TParam extends `${infer Name}*` ? Name : TParam : never /** * Extracts the actual type from a parameter type, handling getters and setters. * @template TParam - The parameter type. * @returns The extracted type, or 'string' as a fallback. */ export type ExtractParamType = Param extends TParam ? unknown : TParam extends ParamGetSet ? Type : TParam extends DateConstructor ? Date : TParam extends JSON ? unknown : TParam extends ParamGetter ? ReturnType : TParam extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : TParam extends LiteralParam ? TParam : string export type ParamIsOptional = TParam extends `?${string}` ? true : false export type ParamIsGreedy = TParam extends `${string}*` ? true : false export type ParamIsOptionalOrHasDefault = ParamIsOptional extends true ? true : TParam extends Required ? true : false ================================================ FILE: src/types/prefetch.ts ================================================ /** * Determines when assets are prefetched. * eager: Fetched immediately * lazy: Fetched when visible */ export type PrefetchStrategy = 'eager' | 'lazy' | 'intent' export type PrefetchConfigOptions = { /** * When true any component that is wrapped in vue's defineAsyncComponent will be prefetched * @default 'eager' */ components?: boolean | PrefetchStrategy, /** * When true any props for routes will be prefetched * @default false */ props?: boolean | PrefetchStrategy, } /** * Determines what assets are prefetched. A boolean enables or disables all prefetching. */ export type PrefetchConfig = boolean | PrefetchStrategy | PrefetchConfigOptions export type PrefetchConfigs = { routerPrefetch?: PrefetchConfig, routePrefetch?: PrefetchConfig, linkPrefetch?: PrefetchConfig, } ================================================ FILE: src/types/props.ts ================================================ import { CreateRouteOptions, PropsGetter } from '@/types/createRouteOptions' import { Route } from '@/types/route' import { RouterReject } from './routerReject' import { RouterPush } from './routerPush' import { RouterReplace } from './routerReplace' import { ExtractRouteContextRejections, ExtractRouteContextRoutes } from './routeContext' import { ResolvedRoute } from './resolved' import { RouteUpdate } from './routeUpdate' /** * Context provided to props callback functions */ export type PropsCallbackContext< TRoute extends Route = Route, TOptions extends CreateRouteOptions = CreateRouteOptions > = { reject: RouterReject>, push: RouterPush<[TRoute] | ExtractRouteContextRoutes>, replace: RouterReplace<[TRoute] | ExtractRouteContextRoutes>, update: RouteUpdate>, parent: PropsCallbackParent, } export type PropsCallbackParent< TParent extends Route | undefined = Route | undefined > = Route | undefined extends TParent ? undefined | { name: string, props: unknown } : TParent extends Route ? { name: TParent['name'], props: GetParentPropsReturnType } : undefined type GetParentPropsReturnType< TParent extends Route | undefined = Route | undefined > = TParent extends Route ? TParent['matched']['props'] extends PropsGetter ? ReturnType : TParent['matched']['props'] extends Record ? { [K in keyof TParent['matched']['props']]: ReturnType } : undefined : undefined ================================================ FILE: src/types/querySource.ts ================================================ export type QuerySource = ConstructorParameters[0] ================================================ FILE: src/types/redirects.spec-d.ts ================================================ import { createRoute } from '@/services/createRoute' import { describe, expectTypeOf, test } from 'vitest' const toWithoutParams = createRoute({ name: 'to', path: '/to', }) const fromWithoutParams = createRoute({ name: 'from', path: '/from', }) const toWithParams = createRoute({ name: 'to', path: '/to/[toPathParam]', query: 'to=[toQueryParam]', }) const fromWithParams = createRoute({ name: 'from', path: '/from/[fromPathParam]', query: 'from=[fromQueryParam]', }) describe('redirectTo', () => { test('no params are correctly typed', () => { fromWithoutParams.redirectTo(toWithoutParams) }) test('callback params are correctly typed', () => { fromWithParams.redirectTo(toWithParams, (params) => { expectTypeOf(params).toEqualTypeOf<{ fromPathParam: string, fromQueryParam: string }>() return { toPathParam: 'string', toQueryParam: 'string' } }) }) test('does not accept missing params', () => { // @ts-expect-error - missing params fromWithParams.redirectTo(toWithParams, () => { return { toQueryParam: 'string' } }) }) test('does not accept invalid to params', () => { // @ts-expect-error - invalid params fromWithParams.redirectTo(toWithParams, () => { return { toPathParam: true, toQueryParam: 'string' } }) }) }) describe('redirectFrom', () => { test('no params are correctly typed', () => { toWithoutParams.redirectFrom(fromWithoutParams) }) test('callback params are correctly typed', () => { toWithParams.redirectFrom(fromWithParams, (params) => { expectTypeOf(params).toEqualTypeOf<{ fromPathParam: string, fromQueryParam: string }>() return { toPathParam: 'string', toQueryParam: 'string' } }) }) test('does not accept missing params', () => { // @ts-expect-error - missing params toWithParams.redirectFrom(fromWithParams, () => { return { toQueryParam: 'string' } }) }) test('does not accept invalid to params', () => { // @ts-expect-error - invalid params toWithParams.redirectFrom(fromWithParams, () => { return { toPathParam: true, toQueryParam: 'string' } }) }) }) ================================================ FILE: src/types/redirects.ts ================================================ import { ResolvedRouteUnion } from './resolved' import { Route, Routes } from './route' import { RouterReplace } from './routerReplace' import { UrlParamsReading, UrlParamsWriting } from './url' import { AllPropertiesAreOptional, MaybePromise } from './utilities' export type RouteRedirects< TRoute extends Route = Route > = { /** * Creates a redirect to redirect the current route to another route. */ redirectTo: RouteRedirectTo, /** * Creates a redirect to redirect to the current route from another route. */ redirectFrom: RouteRedirectFrom, } type RedirectHookContext< TRoutes extends Routes > = { replace: RouterReplace, } export type RedirectHook< TRoutes extends Routes = Routes, TRouteTo extends Route = TRoutes[number] > = (to: ResolvedRouteUnion, context: RedirectHookContext) => MaybePromise export type RouteRedirectCallback< TRouteTo extends Route = Route, TRouteFrom extends Route = Route > = (params: UrlParamsReading) => UrlParamsWriting /** * This type is purposely wide to prevent type errors in RouteRedirectFrom where the TRouteTo generic cannot be inferred. */ export type RouteRedirect = (to: Route, callback?: (params: any) => any) => void export type RedirectToArgs< TRouteTo extends Route = Route, TRouteFrom extends Route = Route > = AllPropertiesAreOptional> extends true ? [to: TRouteTo, params?: RouteRedirectCallback] : [to: TRouteTo, params: RouteRedirectCallback] export type RouteRedirectTo< TRouteFrom extends Route = Route > = (...args: RedirectToArgs) => void export type RedirectFromArgs< TRouteTo extends Route = Route, TRouteFrom extends Route = Route > = AllPropertiesAreOptional> extends true ? [from: TRouteFrom, params?: RouteRedirectCallback] : [from: TRouteFrom, params: RouteRedirectCallback] export type RouteRedirectFrom< TRouteTo extends Route = Route > = (...args: RedirectFromArgs) => void ================================================ FILE: src/types/register.spec.ts ================================================ import { expectTypeOf, test } from 'vitest' import { RouteMeta } from './register' test('given route meta in router options, RouteMeta is correct', () => { type Meta = RouteMeta<{ routeMeta: { zoo: number } }> expectTypeOf().toEqualTypeOf<{ zoo: number }>() }) ================================================ FILE: src/types/register.ts ================================================ import { Router } from '@/types/router' /** * Represents the state of currently registered router, and route meta. Used to provide correct type context for * components like `RouterLink`, as well as for composables like `useRouter`, `useRoute`, and hooks. * * @example * ```ts * declare module '@kitbag/router' { * interface Register { * router: typeof router * routeMeta: { public?: boolean } * } * } * ``` */ export interface Register {} /** * Represents the Router property within {@link Register} */ export type RegisteredRouter = T extends { router: infer TRouter } ? TRouter : Router /** * Represents additional metadata associated with a route, customizable via declaration merging. */ export type RouteMeta = T extends { routeMeta: infer RouteMeta extends Record } ? RouteMeta : Record ================================================ FILE: src/types/rejection.ts ================================================ import { Ref } from 'vue' import { Route } from '@/types/route' import { Router } from '@/types/router' import { RouterReject } from '@/types/routerReject' import { Hooks } from '@/models/hooks' export const BUILT_IN_REJECTION_TYPES = ['NotFound'] as const export type BuiltInRejectionType = (typeof BUILT_IN_REJECTION_TYPES)[number] export type RouterRejection = Ref export type RouterRejections = TRouter['reject'] extends RouterReject ? TRejections[number] : never export const IS_REJECTION_SYMBOL = Symbol('IS_REJECTION_SYMBOL') export function isRejection(value: unknown): value is Rejection & RejectionInternal { return typeof value === 'object' && value !== null && IS_REJECTION_SYMBOL in value } export type RejectionInternal = { [IS_REJECTION_SYMBOL]: true, route: Route, hooks: Hooks[], } /** * Represents an immutable array of Rejection instances. */ export type Rejections = readonly Rejection[] export type Rejection = { /** * The type of rejection. */ type: TType, } export type RejectionType = unknown extends TRejections ? never : Rejections extends TRejections ? string : undefined extends TRejections ? string : TRejections extends Rejections ? TRejections[number]['type'] : never export type ExtractRejections = T extends { rejections: infer TRejections extends Rejections } ? TRejections : [] export type ExtractRejectionTypes = T[number]['type'] extends string ? T[number]['type'] : never ================================================ FILE: src/types/resolved.spec-d.ts ================================================ import { expectTypeOf, test } from 'vitest' import { RouterRoute } from '@/types/routerRoute' import { ResolvedRoute } from '@/types/resolved' import { withParams } from '@/services/withParams' import { createRoute } from '@/services/createRoute' test('given a specific Route, params are narrow', () => { const testRoute = createRoute({ name: 'parentA', path: '/parentA/[paramA]', query: withParams('foo=[paramB]&bar=[?paramC]', { paramB: Boolean }), }) type TestRoute = typeof testRoute type Source = RouterRoute>['params'] type Expect = { paramA: string, paramB: boolean, paramC?: string | undefined } expectTypeOf().toEqualTypeOf() }) test('without a specific Route, params are Record', () => { type Source = RouterRoute['params'] type Expect = Record expectTypeOf().toEqualTypeOf() }) ================================================ FILE: src/types/resolved.ts ================================================ import { Route, Routes } from '@/types/route' import { ExtractRouteStateParamsAsOptional } from '@/types/state' import { UrlString } from '@/types/urlString' import { UrlParamsReading } from '@/types/url' /** * Represents a route that the router has matched to current browser location. * @template TRoute - Underlying Route that has been resolved. */ export type ResolvedRoute = Readonly<{ /** * Unique identifier for the route, generated by router. */ id: TRoute['id'], /** * The specific route properties that were matched in the current route. */ matched: TRoute['matched'], /** * The specific route properties that were matched in the current route, including any ancestors. * Order of routes will be from greatest ancestor to narrowest matched. */ matches: TRoute['matches'], /** * Unique identifier for the route. Name is used for routing and for matching. */ name: TRoute['name'], /** * Key value pair for route params, values will be the user provided value from current browser location. */ params: UrlParamsReading, /** * Type for additional data intended to be stored in history state. */ state: ExtractRouteStateParamsAsOptional, /** * String value of the resolved URL. */ href: UrlString, /** * Query value of the route. */ query: URLSearchParams, /** * Hash value of the route. */ hash: string, /** * Title of the route. */ title: Promise, }> /** * This type is the same as `ResolvedRoute` while remaining distributive */ export type RouterResolvedRouteUnion = { [K in keyof TRoutes]: ResolvedRoute }[number] /** * Converts a union of Route types to a union of ResolvedRoute types while preserving the discriminated union structure for narrowing. * This is useful when you have a Route union (like `TRoutes[number]`) and need it to narrow properly. * Uses a distributive conditional type to ensure unions are properly distributed. * * @example * type RouteUnion = RouteA | RouteB * type ResolvedUnion = ResolvedRouteUnion // ResolvedRoute | ResolvedRoute */ export type ResolvedRouteUnion = TRoute extends Route ? ResolvedRoute : never ================================================ FILE: src/types/route.spec-d.ts ================================================ import { describe, expectTypeOf, test } from 'vitest' import { withParams } from '@/services/withParams' import { withDefault } from '@/services/withDefault' import { createRoute } from '@/services/createRoute' import { createExternalRoute } from '@/services/createExternalRoute' import { UrlParamsReading, UrlParamsWriting } from '@/types/url' describe('ExtractRouteParamTypes', () => { test('when reading/writing params, given routes with different params, some optional, no defaults, combines into expected args', () => { const testRoute = createExternalRoute({ name: 'parentA', host: 'https://[inHost].dev', path: '/[inPath]', query: withParams('foo=[inQuery]&bar=[?paramC]', { inQuery: Boolean, }), hash: '[inHash]', }) type TestRoute = typeof testRoute type SourceWriting = UrlParamsWriting type SourceReading = UrlParamsReading type Expect = { inHost: string, inPath: string, paramC?: string, inQuery: boolean, inHash: string } expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() }) test('when optional params have defaults, Reading and Writing change', () => { const testRoute = createRoute({ name: 'parentA', host: 'https://kitbag.dev', path: '/', query: withParams('foo=[required]&bar=[?optional]', { required: Boolean, optional: withDefault(String, 'ABC'), }), }) type TestRoute = typeof testRoute type SourceWriting = UrlParamsWriting type SourceReading = UrlParamsReading expectTypeOf().toEqualTypeOf<{ required: boolean, optional?: string | undefined }>() expectTypeOf().toEqualTypeOf<{ required: boolean, optional: string }>() }) }) ================================================ FILE: src/types/route.ts ================================================ import { Param } from '@/types/paramTypes' import { PrefetchConfig } from '@/types/prefetch' import { RouteMeta } from '@/types/register' import { LastInArray } from '@/types/utilities' import { CreateRouteOptions } from '@/types/createRouteOptions' import { RouteContext } from '@/types/routeContext' import { Url } from '@/types/url' import { GetRouteTitle } from '@/types/routeTitle' import { Hooks } from '@/models/hooks' import { RouteRedirect } from './redirects' export const IS_ROUTE_SYMBOL = Symbol('IS_ROUTE_SYMBOL') export function isRoute(value: unknown): value is Route & RouteInternal { return typeof value === 'object' && value !== null && IS_ROUTE_SYMBOL in value } export type RouteInternal = { [IS_ROUTE_SYMBOL]: true, depth: number, hooks: Hooks[], redirect: RouteRedirect, getTitle: GetRouteTitle, } /** * Represents an immutable array of Route instances. Return value of `createRoute`, expected param for `createRouter`. */ export type Routes = readonly Route[] /** * The Route properties originally provided to `createRoute`. The only change is normalizing meta to always default to an empty object. */ export type CreatedRouteOptions = Omit & { id: string, // todo: this should not be optional props?: unknown, } /** * Represents the structure of a route within the application. Return value of `createRoute` * @template TName - Represents the unique name identifying the route, typically a string. * @template TPath - The type or structure of the route's path. * @template TQuery - The type or structure of the query parameters associated with the route. */ export type Route< TName extends string = string, TUrl extends Url = Url, TMeta extends RouteMeta = RouteMeta, TState extends Record = Record, TMatches extends CreatedRouteOptions[] = CreatedRouteOptions[], TContext extends RouteContext[] = RouteContext[] > = TUrl & { /** * Unique identifier for the route, generated by router. */ id: string, /** * The specific route properties that were matched in the current route. */ matched: LastInArray, /** * The specific route properties that were matched in the current route, including any ancestors. * Order of routes will be from greatest ancestor to narrowest matched. */ matches: TMatches, /** * Identifier for the route as defined by user. Name must be unique among named routes. Name is used for routing and for matching. */ name: TName, /** * Represents additional metadata associated with a route, combined with any parents. */ meta: TMeta, /** * Represents the schema of the route state, combined with any parents. */ state: TState, /** * Determines what assets are prefetched when router-link is rendered for this route. Overrides router level prefetch. */ prefetch?: PrefetchConfig, /** * Related routes and rejections for the route. The context is exposed to the hooks and props callback functions for this route. */ context: TContext, } export type GenericRoute = Url & { id: string, name: string, matched: CreatedRouteOptions, matches: CreatedRouteOptions[], meta: RouteMeta, state: Record, prefetch?: PrefetchConfig, } ================================================ FILE: src/types/routeContext.ts ================================================ import { CreateRouteOptions } from './createRouteOptions' import { Rejection, Rejections } from './rejection' import { GenericRoute, Route, Routes } from './route' export type RouteContext = GenericRoute | Rejection export type ToRouteContext = TContext extends RouteContext[] ? TContext : [] export type ExtractRouteContext< TOptions extends CreateRouteOptions > = TOptions extends { context: infer TContext extends RouteContext[] } ? TOptions extends { parent: infer TParent extends Route } ? [...ToRouteContext, ...TContext] : TContext : TOptions extends { parent: infer TParent extends Route } ? ToRouteContext : [] export type ExtractRouteContextRoutes< TOptions extends CreateRouteOptions > = RouteContextToRoute> export type ExtractRouteContextRejections< TOptions extends CreateRouteOptions > = RouteContextToRejection> export type RouteContextToRoute = RouteContext[] extends TContext ? Routes : undefined extends TContext ? Routes : FilterRouteContextRoutes type FilterRouteContextRoutes = TContext extends [infer First, ...infer Rest extends RouteContext[]] ? First extends GenericRoute ? [First, ...FilterRouteContextRoutes] : FilterRouteContextRoutes : [] export type RouteContextToRejection = RouteContext[] extends TContext ? Rejections : undefined extends TContext ? Rejections : FilterRouteContextRejections type FilterRouteContextRejections = TContext extends [infer First, ...infer Rest extends RouteContext[]] ? First extends Rejection ? [First, ...FilterRouteContextRejections] : FilterRouteContextRejections : [] ================================================ FILE: src/types/routeTitle.browser.spec.ts ================================================ import { expect, test, vi } from 'vitest' import { createRoute } from '@/services/createRoute' import { component } from '@/utilities/testHelpers' import { createRouter } from '@/services/createRouter' import { flushPromises } from '@vue/test-utils' test('route with title updates document title', async () => { const route = createRoute({ name: 'root', path: '/', component, }) const title = 'foo' const callback = vi.fn(() => title) route.setTitle(callback) const router = createRouter([route], { initialUrl: '/', }) await router.start() await flushPromises() expect(callback).toHaveBeenCalledTimes(1) expect(document.title).toBe(title) }) test('route with title and parent with title does not call parent getTitle', async () => { const parent = createRoute({ name: 'parent', path: '/parent', component, }) const parentGetTitle = vi.fn(() => 'parent') parent.setTitle(parentGetTitle) const child = createRoute({ name: 'child', path: '/child', component, parent, }) const childGetTitle = vi.fn(() => 'child') child.setTitle(childGetTitle) const router = createRouter([parent, child], { initialUrl: '/parent/child', }) await router.start() await flushPromises() expect(parentGetTitle).not.toHaveBeenCalled() expect(childGetTitle).toHaveBeenCalledTimes(1) expect(document.title).toBe('child') }) test('route with title and parent with title does call parent getTitle when called directly', async () => { const parent = createRoute({ name: 'parent', path: '/parent', component, }) const parentGetTitle = vi.fn(() => 'parent') parent.setTitle(parentGetTitle) const child = createRoute({ name: 'child', path: '/child', component, parent, }) const childGetTitle = vi.fn(async (_to, { getParentTitle }) => { const parentTitle = await getParentTitle() return `${parentTitle} - child` }) child.setTitle(childGetTitle) const router = createRouter([parent, child], { initialUrl: '/parent/child', }) await router.start() await flushPromises() expect(parentGetTitle).toHaveBeenCalledTimes(1) expect(childGetTitle).toHaveBeenCalledTimes(1) expect(document.title).toBe('parent - child') }) test('route with title and parent with title does call parent getTitle when called directly', async () => { const parent = createRoute({ name: 'parent', path: '/parent', component, }) const parentGetTitle = vi.fn(() => 'parent') parent.setTitle(parentGetTitle) const child = createRoute({ name: 'child', path: '/child', component, parent, }) const grandchild = createRoute({ name: 'grandchild', path: '/grandchild', component, parent: child, }) const grandchildGetTitle = vi.fn(async (_to, { getParentTitle }) => { const parentTitle = await getParentTitle() return `${parentTitle} - grandchild` }) grandchild.setTitle(grandchildGetTitle) const router = createRouter([parent, child, grandchild], { initialUrl: '/parent/child/grandchild', }) await router.start() await flushPromises() expect(parentGetTitle).toHaveBeenCalledTimes(1) expect(grandchildGetTitle).toHaveBeenCalledTimes(1) expect(document.title).toBe('parent - grandchild') }) test('route without title and parent with title updates document title', async () => { const parent = createRoute({ name: 'parent', path: '/parent', component, }) parent.setTitle(() => 'parent') const child = createRoute({ name: 'child', path: '/child', component, parent, }) const router = createRouter([parent, child], { initialUrl: '/parent/child', }) await router.start() await flushPromises() expect(document.title).toBe('parent') }) ================================================ FILE: src/types/routeTitle.ts ================================================ import { ResolvedRoute, ResolvedRouteUnion } from '@/types/resolved' import { isRoute, Route } from './route' import { MaybePromise } from './utilities' export type SetRouteTitleContext = { from: ResolvedRoute, getParentTitle: () => Promise, } export type SetRouteTitleCallback = (to: ResolvedRouteUnion, context: SetRouteTitleContext) => MaybePromise export type GetRouteTitle = (to: ResolvedRouteUnion) => Promise export type SetRouteTitle = (callback: SetRouteTitleCallback) => void export type RouteSetTitle = { /** * Adds a callback to set the document title for the route. */ setTitle: SetRouteTitle, } type CreateRouteTitle = { setTitle: SetRouteTitle, getTitle: GetRouteTitle, } export function createRouteTitle(parent?: Route): CreateRouteTitle { let setTitleCallback: SetRouteTitleCallback | undefined const setTitle: SetRouteTitle = (callback) => { setTitleCallback = callback } const getTitle: GetRouteTitle = async (to) => { const getParentTitle = async (): Promise => { if (parent && isRoute(parent)) { return parent.getTitle(to) } return undefined } if (!setTitleCallback) { return getParentTitle() } return setTitleCallback(to, { from: to, getParentTitle, }) } return { setTitle, getTitle, } } ================================================ FILE: src/types/routeUpdate.ts ================================================ import { ResolvedRoute } from '@/types/resolved' import { RouterPushOptions } from '@/types/routerPush' export type RouteUpdate = ResolvedRoute extends TRoute ? { (paramName: string, paramValue: unknown, options?: RouterPushOptions): Promise, (params: Partial, options?: RouterPushOptions): Promise, } : { (paramName: TParamName, paramValue: TRoute['params'][TParamName], options?: RouterPushOptions): Promise, (params: Partial, options?: RouterPushOptions): Promise, } ================================================ FILE: src/types/routeWithParams.spec-d.ts ================================================ import { expectTypeOf, test } from 'vitest' import { RouteGetByKey } from '@/types/routeWithParams' import { createRoute } from '@/services/createRoute' test('RouteGetByName works as expected', () => { const parentA = createRoute({ name: 'parentA', path: '/parentA/[paramA]', }) const childA = createRoute({ parent: parentA, name: 'parentA.childA', path: '/childA/[?paramB]', }) type Routes = [typeof parentA, typeof childA] type Source = RouteGetByKey type Expect = typeof childA expectTypeOf().toEqualTypeOf() }) ================================================ FILE: src/types/routeWithParams.ts ================================================ import { Route, Routes } from '@/types/route' import { RoutesName, RoutesMap } from '@/types/routesMap' import { UrlParamsWriting } from '@/types/url' export type RouteGetByKey> = RoutesMap[TKey] export type RouteParamsByKey< TRoutes extends Routes, TKey extends string > = RouteGetByKey extends Route ? UrlParamsWriting> : Record ================================================ FILE: src/types/router.ts ================================================ import { App, InjectionKey, Ref } from 'vue' import { RouterHistoryMode } from '@/services/createRouterHistory' import { RouterRoute } from '@/types/routerRoute' import { AddBeforeEnterHook, AddBeforeUpdateHook, AddBeforeLeaveHook, AddAfterEnterHook, AddAfterUpdateHook, AddAfterLeaveHook, AddErrorHook, AddRejectionHook } from '@/types/hooks' import { PrefetchConfig } from '@/types/prefetch' import { ResolvedRoute } from '@/types/resolved' import { Route, Routes } from '@/types/route' import { RouterPush } from '@/types/routerPush' import { RouterReplace } from '@/types/routerReplace' import { RouterResolve, RouterResolveOptions } from '@/types/routerResolve' import { RouterReject } from '@/types/routerReject' import { RouterPlugin } from '@/types/routerPlugin' import { RoutesName } from '@/types/routesMap' import { ExtractRejections, ExtractRejectionTypes, Rejections } from '@/types/rejection' import { BuiltInRejectionType } from '@/types/rejection' /** * Options to initialize a {@link Router} instance. */ export type RouterOptions = { /** * Initial URL for the router to use. Required if using Node environment. Defaults to window.location when using browser. * * @default window.location.toString() */ initialUrl?: string, /** * Specifies the history mode for the router, such as "browser", "memory", or "hash". * * @default "auto" */ historyMode?: RouterHistoryMode, /** * Base path to be prepended to any URL. Can be used for Vue applications that run in nested folder for domain. * For example having `base` of `/foo` would assume all routes should start with `your.domain.com/foo`. */ base?: string, /** * Determines what assets are prefetched when router-link is rendered for a specific route */ prefetch?: PrefetchConfig, /** * Components assigned to each type of rejection your router supports. */ rejections?: Rejections, /** * Removes trailing slashes from the URL before matching routes. The browser's url is updated to reflect using `router.replace`. * * @default true */ removeTrailingSlashes?: boolean, /** * When false, createRouterAssets must be used for component and hooks. Assets exported by the library * will not work with the created router instance. * * @default true */ isGlobalRouter?: boolean, } export type Router< TRoutes extends Routes = any, TOptions extends RouterOptions = any, TPlugin extends RouterPlugin = any > = { /** * Installs the router into a Vue application instance. * @param app The Vue application instance to install the router into */ install: (app: App) => void, /** * Manages the current route state. */ route: RouterRouteUnion | RouterRouteUnion, /** * Creates a ResolvedRoute record for a given route name and params. */ resolve: RouterResolve, /** * Creates a ResolvedRoute record for a given URL. */ find: (url: string, options?: RouterResolveOptions) => ResolvedRoute | undefined, /** * Navigates to a specified path or route object in the history stack, adding a new entry. */ push: RouterPush, /** * Replaces the current entry in the history stack with a new one. */ replace: RouterReplace, /** * Handles route rejection based on a specified rejection type. */ reject: RouterReject<[...ExtractRejections, ...ExtractRejections]>, /** * Forces the router to re-evaluate the current route. */ refresh: () => void, /** * Navigates to the previous entry in the browser's history stack. */ back: () => void, /** * Navigates to the next entry in the browser's history stack. */ forward: () => void, /** * Moves the current history entry to a specific point in the history stack. */ go: (delta: number) => void, /** * Registers a hook to be called before a route is entered. */ onBeforeRouteEnter: AddBeforeEnterHook | ExtractRejections>, /** * Registers a hook to be called before a route is left. */ onBeforeRouteLeave: AddBeforeLeaveHook | ExtractRejections>, /** * Registers a hook to be called before a route is updated. */ onBeforeRouteUpdate: AddBeforeUpdateHook | ExtractRejections>, /** * Registers a hook to be called after a route is entered. */ onAfterRouteEnter: AddAfterEnterHook | ExtractRejections>, /** * Registers a hook to be called after a route is left. */ onAfterRouteLeave: AddAfterLeaveHook | ExtractRejections>, /** * Registers a hook to be called after a route is updated. */ onAfterRouteUpdate: AddAfterUpdateHook | ExtractRejections>, /** * Registers a hook to be called when an error occurs. * If the hook returns true, the error is considered handled and the other hooks are not run. If all hooks return false the error is rethrown */ onError: AddErrorHook | ExtractRejections>, /** * Registers a hook to be called when a rejection occurs. */ onRejection: AddRejectionHook> | ExtractRejectionTypes> | BuiltInRejectionType, TRoutes | TPlugin['routes']>, /** * Given a URL, returns true if host does not match host stored on router instance */ isExternal: (url: string) => boolean, /** * Determines what assets are prefetched. */ prefetch?: PrefetchConfig, /** * Initializes the router based on the initial route. Automatically called when the router is installed. Calling this more than once has no effect. */ start: () => Promise, /** * Returns true if the router has been started. */ started: Ref, /** * Stops the router and teardown any listeners. */ stop: () => void, /** * Returns the key of the router. * * @private */ key: InjectionKey>, /** * Returns true if the router's devtools plugin has been installed * @private */ hasDevtools: boolean, } /** * This type is the same as `RouterRoute>` while remaining distributive. * Routes without a name (empty string) are excluded so that router.route.name is never ''. */ export type RouterRouteUnion = { [K in keyof TRoutes]: TRoutes[K]['name'] extends '' ? never : RouterRoute> }[number] export type RouterRoutes = TRouter extends Router ? TRoutes : Routes export type RouterRejections = TRouter extends Router ? ExtractRejections | ExtractRejections : [] export type RouterRouteName = TRouter extends Router ? RoutesName : RoutesName ================================================ FILE: src/types/routerAbort.ts ================================================ export type RouterAbort = () => void ================================================ FILE: src/types/routerLink.ts ================================================ import { PrefetchConfig } from '@/types/prefetch' import { UrlString } from '@/types/urlString' import { ResolvedRoute } from '@/types/resolved' import { Router } from '@/types/router' import { RouterPushOptions } from '@/types/routerPush' export type ToCallback = (resolve: TRouter['resolve']) => ResolvedRoute | UrlString | undefined export type RouterLinkProps = RouterPushOptions & { /** * The url string to navigate to or a callback that returns a url string */ to: UrlString | ResolvedRoute | ToCallback, /** * Determines what assets are prefetched when router-link is rendered for this route. Overrides route level prefetch. */ prefetch?: PrefetchConfig, /** * The target attribute for the anchor element. */ target?: string, } ================================================ FILE: src/types/routerPlugin.ts ================================================ import { HookRemove } from './hooks' import { Routes } from './route' import { Rejections } from './rejection' import { Hooks } from '@/models/hooks' import { ResolvedRoute } from './resolved' import { RouterReject } from './routerReject' import { RouterPush } from './routerPush' import { RouterReplace } from './routerReplace' import { CallbackContextAbort } from '@/services/createRouterCallbackContext' import { MaybePromise } from './utilities' export type EmptyRouterPlugin = RouterPlugin<[], []> export const IS_ROUTER_PLUGIN_SYMBOL = Symbol('IS_ROUTER_PLUGIN_SYMBOL') export function isRouterPlugin(value: unknown): value is RouterPlugin & RouterPluginInternal { return typeof value === 'object' && value !== null && IS_ROUTER_PLUGIN_SYMBOL in value } export type RouterPluginInternal = { [IS_ROUTER_PLUGIN_SYMBOL]: true, hooks: Hooks, } export type CreateRouterPluginOptions< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = { routes?: TRoutes, rejections?: TRejections, } export type RouterPlugin< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = { /** * The routes supplied by the plugin. */ routes: TRoutes, /** * The rejections supplied by the plugin. */ rejections: TRejections, } type PluginBeforeRouteHookContext< TRoutes extends Routes, TRejections extends Rejections > = { from: ResolvedRoute | null, reject: RouterReject, push: RouterPush, replace: RouterReplace, abort: CallbackContextAbort, } type PluginAfterRouteHookContext< TRoutes extends Routes, TRejections extends Rejections > = { from: ResolvedRoute | null, reject: RouterReject, push: RouterPush, replace: RouterReplace, } export type PluginBeforeRouteHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = (to: ResolvedRoute, context: PluginBeforeRouteHookContext) => MaybePromise export type PluginAfterRouteHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = (to: ResolvedRoute, context: PluginAfterRouteHookContext) => MaybePromise type AddPluginBeforeRouteHook< TRoutes extends Routes, TRejections extends Rejections > = (hook: PluginBeforeRouteHook) => HookRemove type AddPluginAfterRouteHook< TRoutes extends Routes, TRejections extends Rejections > = (hook: PluginAfterRouteHook) => HookRemove export type PluginErrorHookContext< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = { to: ResolvedRoute, from: ResolvedRoute | null, source: 'props' | 'hook' | 'component', reject: RouterReject, push: RouterPush, replace: RouterReplace, } export type PluginErrorHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = (error: unknown, context: PluginErrorHookContext) => void export type AddPluginErrorHook< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = (hook: PluginErrorHook) => HookRemove export type PluginRouteHooks< TRoutes extends Routes = Routes, TRejections extends Rejections = Rejections > = { /** * Registers a global hook to be called before a route is entered. */ onBeforeRouteEnter: AddPluginBeforeRouteHook, /** * Registers a global hook to be called before a route is left. */ onBeforeRouteLeave: AddPluginBeforeRouteHook, /** * Registers a global hook to be called before a route is updated. */ onBeforeRouteUpdate: AddPluginBeforeRouteHook, /** * Registers a global hook to be called after a route is entered. */ onAfterRouteEnter: AddPluginAfterRouteHook, /** * Registers a global hook to be called after a route is left. */ onAfterRouteLeave: AddPluginAfterRouteHook, /** * Registers a global hook to be called after a route is updated. */ onAfterRouteUpdate: AddPluginAfterRouteHook, /** * Registers a global hook to be called when an error occurs. */ onError: AddPluginErrorHook, } ================================================ FILE: src/types/routerPush.ts ================================================ import { Routes } from '@/types/route' import { RoutesName } from '@/types/routesMap' import { RouteParamsByKey } from '@/types/routeWithParams' import { RouteStateByName } from '@/types/state' import { UrlString } from '@/types/urlString' import { AllPropertiesAreOptional } from '@/types/utilities' import { QuerySource } from '@/types/querySource' import { ResolvedRoute } from '@/types/resolved' export type RouterPushOptions< TState = unknown > = { /** * The query string to add to the url. */ query?: QuerySource, /** * The hash to append to the url. */ hash?: string, /** * Whether to replace the current history entry. */ replace?: boolean, /** * State values to pass to the route. */ state?: Partial, } type RouterPushArgs< TRoutes extends Routes, TSource extends RoutesName > = AllPropertiesAreOptional> extends true ? [params?: RouteParamsByKey, options?: RouterPushOptions>] : [params: RouteParamsByKey, options?: RouterPushOptions>] export type RouterPush< TRoutes extends Routes = any > = { >(name: TSource, ...args: RouterPushArgs): Promise, (route: ResolvedRoute, options?: RouterPushOptions): Promise, (url: UrlString, options?: RouterPushOptions): Promise, } ================================================ FILE: src/types/routerReject.ts ================================================ import { BuiltInRejectionType, Rejections, RejectionType } from '@/types/rejection' export type RouterReject = | BuiltInRejectionType)>(type: TSource) => void ================================================ FILE: src/types/routerReplace.ts ================================================ import { Routes } from '@/types/route' import { RoutesName } from '@/types/routesMap' import { RouteParamsByKey } from '@/types/routeWithParams' import { RouteStateByName } from '@/types/state' import { UrlString } from '@/types/urlString' import { AllPropertiesAreOptional } from '@/types/utilities' import { QuerySource } from '@/types/querySource' import { ResolvedRoute } from '@/types/resolved' export type RouterReplaceOptions< TState = unknown > = { query?: QuerySource, hash?: string, state?: Partial, } type RouterReplaceArgs< TRoutes extends Routes, TSource extends RoutesName > = AllPropertiesAreOptional> extends true ? [params?: RouteParamsByKey, options?: RouterReplaceOptions>] : [params: RouteParamsByKey, options?: RouterReplaceOptions>] export type RouterReplace< TRoutes extends Routes > = { >(name: TSource, ...args: RouterReplaceArgs): Promise, (route: ResolvedRoute, options?: RouterReplaceOptions): Promise, (url: UrlString, options?: RouterReplaceOptions): Promise, } ================================================ FILE: src/types/routerResolve.ts ================================================ import { Routes } from '@/types/route' import { RoutesName } from '@/types/routesMap' import { RouteParamsByKey } from '@/types/routeWithParams' import { AllPropertiesAreOptional } from '@/types/utilities' import { QuerySource } from '@/types/querySource' import { ResolvedRoute } from '@/types/resolved' import { RouteStateByName } from '@/types/state' export type RouterResolveOptions< TState = unknown > = { query?: QuerySource, hash?: string, state?: Partial, } type RouterResolveArgs< TRoutes extends Routes, TSource extends RoutesName > = AllPropertiesAreOptional> extends true ? [params?: RouteParamsByKey, options?: RouterResolveOptions>] : [params: RouteParamsByKey, options?: RouterResolveOptions>] export type RouterResolve< TRoutes extends Routes > = >(name: TSource, ...args: RouterResolveArgs) => ResolvedRoute ================================================ FILE: src/types/routerRoute.ts ================================================ import { RouteUpdate } from '@/types/routeUpdate' import { ResolvedRoute } from '@/types/resolved' import { QuerySource } from '@/types/querySource' export type RouterRoute = { /** * Unique identifier for the route, generated by router. */ readonly id: TRoute['id'], /** * Identifier for the route as defined by user. Name must be unique among named routes. Name is used for routing and for matching. */ readonly name: TRoute['name'], /** * The specific route properties that were matched in the current route. */ readonly matched: TRoute['matched'], /** * The specific route properties that were matched in the current route, including any ancestors. * Order of routes will be from greatest ancestor to narrowest matched. */ readonly matches: TRoute['matches'], /** * Hash value of the route. */ readonly hash: string, /** * Update the route. */ readonly update: RouteUpdate, /** * String value of the resolved URL. */ readonly href: TRoute['href'], /** * Title of the route. */ readonly title: TRoute['title'], params: TRoute['params'], state: TRoute['state'], get query(): URLSearchParams, set query(value: QuerySource), } ================================================ FILE: src/types/routesMap.spec-ts.ts ================================================ import { expectTypeOf, test } from 'vitest' import { createRoute } from '@/services/createRoute' import { Route } from '@/types/route' import { RoutesMap } from '@/types/routesMap' import { component } from '@/utilities/testHelpers' test('RoutesMap given generic routes, returns generic string', () => { type Map = RoutesMap type Source = Map[keyof Map]['name'] type Expect = string expectTypeOf().toEqualTypeOf() }) test('RoutesMap given unnamed parents, removes them from return value and children names', () => { const root = createRoute({ path: '/', }) const foo = createRoute({ parent: root, name: 'foo', path: '/foo', component, }) const zooFoo = createRoute({ name: 'zoofoo', path: '/zoofoo', component, parent: foo }) const bar = createRoute({ parent: root, path: '/bar', component, }) const zooBar = createRoute({ name: 'zoo', path: '/zoo', component, parent: bar }) // eslint-disable-next-line @typescript-eslint/no-unused-vars const routes = [ root, foo, zooFoo, bar, zooBar, ] type Map = RoutesMap type Source = keyof Map type Expect = 'foo' | 'zoofoo' | 'zoo' expectTypeOf().toEqualTypeOf() }) ================================================ FILE: src/types/routesMap.ts ================================================ import { Route, Routes } from '@/types/route' import { StringHasValue } from '@/utilities/guards' type IsRouteUnnamed = StringHasValue extends true ? false : true type AsNamedRoute = IsRouteUnnamed extends true ? never : T export type RoutesMap = { [K in TRoutes[number] as AsNamedRoute['name']]: AsNamedRoute } export type RoutesName = string & keyof RoutesMap ================================================ FILE: src/types/state.ts ================================================ import { ExtractParamName, ExtractParamType } from '@/types/params' import { Param } from '@/types/paramTypes' import { Routes } from '@/types/route' import { RouteGetByKey } from '@/types/routeWithParams' import { MakeOptional } from '@/utilities/makeOptional' import { Identity } from '@/types/utilities' export type ToState | undefined> = TState extends undefined ? Record : unknown extends TState ? {} : TState export type ExtractRouteStateParamsAsOptional> = { [K in keyof TParams]: ExtractParamType | undefined } export type RouteStateByName< TRoutes extends Routes, TName extends string > = ExtractStateParams> type ExtractStateParams = TRoute extends { state: infer TState extends Record, } ? ExtractParamTypes : Record type ExtractParamTypes> = Identity]: ExtractParamType }>> ================================================ FILE: src/types/url.ts ================================================ import { OptionalUrlParam, UrlQueryPart, ToUrlQueryPart, RequiredUrlParam, ToUrlPart, UrlParams, UrlPart } from '@/services/withParams' import { ExtractParamType } from '@/types/params' import { AllPropertiesAreOptional, Identity } from '@/types/utilities' import { UrlString } from '@/types/urlString' import { ParamGetSet } from '@/types/paramTypes' import { MakeOptional } from '@/utilities/makeOptional' export const IS_URL_SYMBOL = Symbol('IS_URL_SYMBOL') export type UrlInternal = { [IS_URL_SYMBOL]: true, schema: Record, params: {}, } export type CreateUrlOptions = { host?: string | UrlPart | undefined, path?: string | UrlPart | undefined, query?: string | UrlQueryPart | undefined, hash?: string | UrlPart | undefined, } export type ToUrl< TOptions extends CreateUrlOptions > = Url['params'] & ToUrlPart['params'] & ToUrlQueryPart['params'] & ToUrlPart['params'] >> export function isUrl(url: unknown): url is Url & UrlInternal { return typeof url === 'object' && url !== null && IS_URL_SYMBOL in url } export type ParseUrlOptions = { /** * Whether to remove trailing slashes from the path. When true, trailing slashes will be removed from the path. * @default true */ removeTrailingSlashes?: boolean, } /** * Represents the structure of a url parts. Can be used to create a url with support for params. */ export type Url = { /** * @internal * The parameters type for the url. Non functional and undefined at runtime. */ params: TParams, /** * Converts the url parts to a full url. */ stringify(...params: UrlParamsArgs): UrlString, /** * Parses the url supplied and returns any params found. */ parse(url: string, options?: ParseUrlOptions): ToUrlParamsReading, /** * Parses the url supplied and returns any params found. */ tryParse(url: string, options?: ParseUrlOptions): { success: true, params: ToUrlParamsReading } | { success: false, params: {}, error: Error }, /** * True if the url is relative. False if the url is absolute. */ isRelative: boolean, } type UrlParamsArgs< TParams extends UrlParams > = AllPropertiesAreOptional> extends true ? [params?: ToUrlParamsWriting] : [params: ToUrlParamsWriting] /** * Extracts combined types of path and query parameters for a given url, creating a unified parameter object. * @template TUrl - The url type from which to extract and merge parameter types. * @returns A record of parameter names to their respective types, extracted and merged from both path and query parameters. */ export type UrlParamsReading = ToUrlParamsReading type ToUrlParamsReading< TParams extends UrlParams > = Identity< MakeOptional<{ [K in keyof TParams]: TParams[K] extends OptionalUrlParam ? TParam extends Required ? ExtractParamType : ExtractParamType | undefined : TParams[K] extends RequiredUrlParam ? ExtractParamType : unknown }> > /** * Extracts combined types of path and query parameters for a given url, creating a unified parameter object. * Differs from ExtractRouteParamTypesReading in that optional params with defaults will remain optional. * @template TUrl - The url type from which to extract and merge parameter types. * @returns A record of parameter names to their respective types, extracted and merged from both path and query parameters. */ export type UrlParamsWriting = ToUrlParamsWriting type ToUrlParamsWriting< TParams extends UrlParams > = Identity< MakeOptional<{ [K in keyof TParams]: TParams[K] extends OptionalUrlParam ? ExtractParamType | undefined : TParams[K] extends RequiredUrlParam ? ExtractParamType : unknown }> > ================================================ FILE: src/types/urlString.ts ================================================ export type UrlString = `http://${string}` | `https://${string}` | `/${string}` /** * A type guard for determining if a value is a valid URL. * @param value - The value to check. * @returns `true` if the value is a valid URL, otherwise `false`. * @group Type Guards */ export function isUrlString(value: unknown): value is UrlString { if (typeof value !== 'string') { return false } const regexPattern = /^(https?:\/\/|\/).*/g return regexPattern.test(value) } /** * Converts a string to a valid URL. * @param value - The string to convert. * @returns The valid URL. */ export function asUrlString(value: string): UrlString { if (isUrlString(value)) { return value } return `/${value}` } ================================================ FILE: src/types/useLink.ts ================================================ import { ComputedRef, Ref } from 'vue' import { PrefetchConfig } from '@/types/prefetch' import { ResolvedRoute } from '@/types/resolved' import { RouterPushOptions } from '@/types/routerPush' import { RouterReplaceOptions } from '@/types/routerReplace' import { UrlString } from '@/types/urlString' export type UseLink = { /** * A template ref to bind to the dom for automatic prefetching */ element: Ref, /** * ResolvedRoute if matched. Same value as `router.find` */ route: ComputedRef, /** * Resolved URL with params interpolated and query applied. Same value as `router.resolve`. */ href: ComputedRef, /** * True if route matches current URL or is ancestor of route that matches current URL */ isMatch: ComputedRef, /** * True if route matches current URL. Route is the same as what's currently stored at `router.route`. */ isExactMatch: ComputedRef, /** * True if route matches current URL, or is a parent route that matches the parent of the current URL. */ isActive: ComputedRef, /** * True if route matches current URL exactly. */ isExactActive: ComputedRef, /** * */ isExternal: ComputedRef, /** * Convenience method for executing `router.push` with route context passed in. */ push: (options?: RouterPushOptions) => Promise, /** * Convenience method for executing `router.replace` with route context passed in. */ replace: (options?: RouterReplaceOptions) => Promise, } export type UseLinkOptions = RouterPushOptions & { prefetch?: PrefetchConfig, } ================================================ FILE: src/types/utilities.ts ================================================ // Utility type that converts types like `{ foo: string } & { bar: string, baz: never }` // into `{ foo: string, bar: string }` // // this is a magic type and don't wanna mess with the {} export type Identity = T extends object ? {} & { [P in keyof T as T[P] extends never ? never : P]: T[P] } : T type IsEmptyObject = T extends Record ? (keyof T extends never ? true : false) : false export type LastInArray = T extends [...any[], infer Last] ? Last : TFallback export type MaybePromise = T | Promise type OnlyRequiredProperties = { [K in keyof T as Extract extends never ? K : never]: T[K] } export type AllPropertiesAreOptional = Record extends T ? true : IsEmptyObject> /** * Converts a type to a string if it is a string, otherwise returns never. * Specifically useful when using keyof T to produce a union of strings * rather than string | number | symbol. */ export type AsString = T extends string ? T : never ================================================ FILE: src/utilities/array.ts ================================================ export function getCount(array: T[], item: T): number { return array.filter((itemAtIndex) => item === itemAtIndex).length } ================================================ FILE: src/utilities/checkDuplicateNames.spec.ts ================================================ import { expect, test } from 'vitest' import { DuplicateNamesError } from '@/errors/duplicateNamesError' import { checkDuplicateNames } from '@/utilities/checkDuplicateNames' import { createRoute } from '@/services/createRoute' test('given routes with all unique names, does nothing', () => { const routes = [ createRoute({ name: 'foo' }), createRoute({ name: 'bar' }), createRoute({ name: 'zoo' }), ] const action: () => void = () => { checkDuplicateNames(routes) } expect(action).not.toThrow() }) test('given routes with duplicate names but same id, does nothing', () => { const duplicate = createRoute({ name: 'duplicate' }) const routes = [ createRoute({ name: 'foo' }), duplicate, createRoute({ name: 'bar' }), duplicate, createRoute({ name: 'zoo' }), ] const action: () => void = () => { checkDuplicateNames(routes) } expect(action).not.toThrow() }) test('given routes with duplicate , throws DuplicateNamesError', () => { const routes = [ createRoute({ name: 'foo' }), createRoute({ name: 'bar' }), createRoute({ name: 'zoo' }), createRoute({ name: 'bar' }), createRoute({ name: 'foo' }), ] const action: () => void = () => { checkDuplicateNames(routes) } expect(action).toThrow(DuplicateNamesError) }) ================================================ FILE: src/utilities/checkDuplicateNames.ts ================================================ import { DuplicateNamesError } from '@/errors/duplicateNamesError' import { Route, Routes } from '@/types/route' export function checkDuplicateNames(routes: Routes): void { routes.reduce((grouped, route) => { if (!grouped.has(route.name)) { grouped.set(route.name, route) return grouped } const existingRoute = grouped.get(route.name) if (existingRoute?.id !== route.id) { throw new DuplicateNamesError(route.name) } return grouped }, new Map()) } ================================================ FILE: src/utilities/checkDuplicateParams.spec.ts ================================================ import { expect, test } from 'vitest' import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { checkDuplicateParams } from '@/utilities/checkDuplicateParams' test('given a single array without duplicates, does nothing', () => { const input = ['foo', 'bar', 'zoo'] const action: () => void = () => { checkDuplicateParams(input) } expect(action).not.toThrow() }) test.each([ [['foo', 'bar', 'zoo', 'bar']], [['foo', 'bar', 'zoo'], ['jar', 'zoo']], [['foo', 'bar', 'zoo'], ['jar'], ['zoo']], ])('given a multiple arrays with duplicates, throws DuplicateParamsError', (...arrays) => { const action: () => void = () => { checkDuplicateParams(...arrays) } expect(action).toThrow(DuplicateParamsError) }) test.each([ [['foo', 'bar', 'zoo']], [['foo', 'bar', 'zoo'], ['jar']], [['foo', 'bar'], ['jar'], ['zoo']], ])('given a multiple arrays without duplicates, does nothing', () => { const aArray = ['foo', 'bar', 'zoo'] const bArray = ['jar'] const action: () => void = () => { checkDuplicateParams(aArray, bArray) } expect(action).not.toThrow() }) test.each([ [{ foo: 'foo', var: 'bar', zoo: 'zoo' }, { jar: 'jar', zoo: 'zoo' }], [{ foo: 'foo', var: 'bar', zoo: 'zoo' }, { jar: 'jar' }, { zoo: 'zoo' }], ])('given multiple records with duplicates keys, throws DuplicateParamsError', (...records) => { const action: () => void = () => { checkDuplicateParams(...records) } expect(action).toThrow(DuplicateParamsError) }) test.each([ [{ foo: 'foo', var: 'bar', zoo: 'zoo' }, { jar: 'jar' }], [{ foo: 'foo', var: 'bar' }, { jar: 'jar' }, { zoo: 'zoo' }], ])('given multiple records without duplicates keys, does nothing', () => { const aRecord = { foo: 'foo', var: 'bar' } const bRecord = { jar: 'jar', zoo: 'zoo' } const action: () => void = () => { checkDuplicateParams(aRecord, bRecord) } expect(action).not.toThrow() }) ================================================ FILE: src/utilities/checkDuplicateParams.ts ================================================ import { DuplicateParamsError } from '@/errors/duplicateParamsError' import { getCount } from '@/utilities/array' export function checkDuplicateParams(...withParams: (Record | string[])[]): void { const paramNames = withParams.flatMap((params) => { return Array.isArray(params) ? params : Object.keys(params) }) for (const paramName of paramNames) { if (getCount(paramNames, paramName) > 1) { throw new DuplicateParamsError(paramName) } } } ================================================ FILE: src/utilities/components.spec.ts ================================================ import { expect, test } from 'vitest' import { defineAsyncComponent } from 'vue' import helloWorld from '@/components/helloWorld' import { isAsyncComponent } from '@/utilities/components' test.each([ [helloWorld, false], [defineAsyncComponent(() => import('@/components/helloWorld')), true], ])('isAsyncComponent returns correct value', (component, isAsync) => { expect(isAsyncComponent(component)).toBe(isAsync) }) ================================================ FILE: src/utilities/components.ts ================================================ import { Component, defineAsyncComponent } from 'vue' /** * A dummy component created to compare its name against other components to determine * if they were wrapped in vue's defineAsyncComponent utility */ const asyncComponent = defineAsyncComponent(() => { return new Promise((resolve) => { resolve({ default: { template: 'foo' } }) }) }) type ComponentWithAsyncLoader = Component & { __asyncLoader: () => void } export function isAsyncComponent(component: Component): component is ComponentWithAsyncLoader { return component.name === asyncComponent.name && '__asyncLoader' in component } ================================================ FILE: src/utilities/guards.spec.ts ================================================ import { describe, expect, expectTypeOf, test } from 'vitest' import { hasProperty, StringHasValue } from '@/utilities/guards' describe('hasProperty', () => { test('returns true when property exists', () => { const source = hasProperty({ foo: true }, 'foo') expect(source).toBe(true) }) test('returns false when property does not exists', () => { const source = hasProperty({}, 'foo') expect(source).toBe(false) }) test('returns true when property exists and is correct type', () => { const source = hasProperty({ foo: true }, 'foo', Boolean) expect(source).toBe(true) }) test('returns false when property exists and is not correct type', () => { const source = hasProperty({ foo: true }, 'foo', String) expect(source).toBe(false) }) }) describe('stringHasValue', () => { test('given empty string, returns false', () => { type Source = StringHasValue<''> type Expect = false expectTypeOf().toEqualTypeOf() }) test('given generic string, returns true', () => { type Source = StringHasValue type Expect = true expectTypeOf().toEqualTypeOf() }) test('given type any, returns true', () => { type Source = StringHasValue type Expect = true expectTypeOf().toEqualTypeOf() }) test('given type other than string or any, returns false', () => { type Source = StringHasValue type Expect = false expectTypeOf().toEqualTypeOf() }) test('given string not empty, returns true', () => { type Source = StringHasValue<'foo'> type Expect = true expectTypeOf().toEqualTypeOf() }) }) ================================================ FILE: src/utilities/guards.ts ================================================ export function isDefined(value: T | undefined): value is T { return value !== undefined } export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } export function isPropertyKey(value: unknown): value is PropertyKey { return typeof value === 'string' || typeof value === 'number' || typeof value === 'symbol' } export function hasProperty< TSource extends Record, TProperty extends PropertyKey, TType extends() => unknown >(value: TSource, key: TProperty, type?: TType): value is TSource & Record> { const propertyExists = isRecord(value) && key in value if (!propertyExists) { return false } if (type) { return typeof value[key] === typeof type() } return true } export function stringHasValue(value: string | undefined): value is string { return typeof value === 'string' && value.length > 0 } export type StringHasValue = string extends T ? true : '' extends T ? false : T extends string ? true : false ================================================ FILE: src/utilities/index.ts ================================================ export * from './array' export * from './checkDuplicateParams' export * from './guards' export * from './isBrowser' export * from './makeOptional' export * from './testHelpers' ================================================ FILE: src/utilities/isBrowser.browser.spec.ts ================================================ import { expect, test } from 'vitest' import { isBrowser } from '@/utilities/isBrowser' test('isBrowser returns true when environment is browser', () => { expect(isBrowser()).toBe(true) }) ================================================ FILE: src/utilities/isBrowser.spec.ts ================================================ import { expect, test } from 'vitest' import { isBrowser } from '@/utilities/isBrowser' test('isBrowser returns false when environment is node', () => { expect(isBrowser()).toBe(false) }) ================================================ FILE: src/utilities/isBrowser.ts ================================================ export function isBrowser(): boolean { return typeof window !== 'undefined' && typeof window.document !== 'undefined' } ================================================ FILE: src/utilities/isNamedRoute.ts ================================================ import { Route } from '@/types/route' import { stringHasValue } from '@/utilities/guards' export function isNamedRoute(route: Route): route is Route & { name: string } { return 'name' in route && stringHasValue(route.name) } ================================================ FILE: src/utilities/makeOptional.ts ================================================ type WithOptionalProperties = { [P in keyof T]-?: undefined extends T[P] ? P : never }[keyof T] export type MakeOptional = { [P in WithOptionalProperties]?: T[P]; } & { [P in Exclude>]: T[P]; } ================================================ FILE: src/utilities/prefetch.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { DEFAULT_PREFETCH_STRATEGY, getPrefetchConfigValue, getPrefetchOption } from './prefetch' describe('getPrefetchOptions', () => { test.each([ [undefined, 'lazy', false, 'lazy'], [true, false, false, DEFAULT_PREFETCH_STRATEGY], [{ components: true }, false, false, DEFAULT_PREFETCH_STRATEGY], [{ components: true }, 'eager', false, 'eager'], [false, 'eager', true, false], [{ components: false }, 'lazy', true, false], [{ components: true }, 'intent', true, 'intent'], [{ components: 'intent' }, 'lazy', true, 'intent'], [false, 'intent', true, false], [undefined, 'intent', true, 'intent'], ] as const)('when given [`%s`, `%s`, `%s`] returns `%s`', (linkPrefetch, routePrefetch, routerPrefetch, expected) => { const value = getPrefetchOption({ linkPrefetch, routePrefetch, routerPrefetch, }, 'components') expect(value).toBe(expected) }) }) describe('getPrefetchConfigValue', () => { test.each([ [false, false], [true, true], [undefined, undefined], ['lazy', 'lazy'], [{ components: false }, false], [{ components: true }, true], [{ components: undefined }, undefined], [{ components: 'lazy' }, 'lazy'], ] as const)('when given `%s` returns `%s`', (input, expected) => { const value = getPrefetchConfigValue(input, 'components') expect(value).toBe(expected) }) }) ================================================ FILE: src/utilities/prefetch.ts ================================================ import { PrefetchConfig, PrefetchConfigOptions, PrefetchConfigs, PrefetchStrategy } from '@/types/prefetch' import { isRecord } from './guards' export const DEFAULT_PREFETCH_STRATEGY: PrefetchStrategy = 'lazy' const DEFAULT_PREFETCH_CONFIG: Required = { components: true, props: false, } function isPrefetchStrategy(value: any): value is PrefetchStrategy { return ['eager', 'lazy', 'intent'].includes(value) } export function getPrefetchOption({ routerPrefetch, routePrefetch, linkPrefetch }: PrefetchConfigs, setting: keyof PrefetchConfigOptions): false | PrefetchStrategy { const link = getPrefetchConfigValue(linkPrefetch, setting) const route = getPrefetchConfigValue(routePrefetch, setting) const router = getPrefetchConfigValue(routerPrefetch, setting) const value = [ link, route, router, DEFAULT_PREFETCH_CONFIG[setting], DEFAULT_PREFETCH_STRATEGY, ].reduce((previous, next) => { if (isPrefetchStrategy(previous)) { return previous } if (previous === true && isPrefetchStrategy(next)) { return next } if (previous === true && !isPrefetchStrategy(next)) { return previous } if (previous === undefined) { return next } return previous }, undefined) if (isPrefetchStrategy(value)) { return value } return false } export function getPrefetchConfigValue(prefetch: PrefetchConfig | undefined, setting: keyof PrefetchConfigOptions): boolean | PrefetchStrategy | undefined { if (isRecord(prefetch)) { return prefetch[setting] } return prefetch } ================================================ FILE: src/utilities/promises.ts ================================================ export function isPromise(value: unknown): value is Promise { return typeof value === 'object' && value !== null && 'then' in value } ================================================ FILE: src/utilities/props.ts ================================================ import { isPromise } from './promises' /** * Takes a props callback and returns a value * For sync props, this will return the props value or an error. * For async props, this will return a promise that resolves to the props value or an error. */ export function getPropsValue(callback: () => unknown): unknown { try { const value = callback() if (isPromise(value)) { return value.catch((error: unknown) => error) } return value } catch (error) { return error } } ================================================ FILE: src/utilities/setDocumentTitle.ts ================================================ import { ResolvedRoute } from '@/types/resolved' import { isRoute } from '@/types/route' import { isBrowser } from '@/utilities/isBrowser' let defaultTitle: string | undefined export function setDocumentTitle(to: ResolvedRoute | null): void { if (!isRoute(to) || !isBrowser()) { return } defaultTitle ??= document.title to.title.then((value) => { document.title = value ?? defaultTitle ?? '' }) } ================================================ FILE: src/utilities/testHelpers.ts ================================================ import { createRoute } from '@/services/createRoute' export const random = { number(options: { min?: number, max?: number } = {}): number { const { min, max } = { min: 0, max: 1, ...options } const randomNumber = Math.floor(Math.random() * (max - min + 1)) + min return randomNumber }, } export function getError(callback: () => any): unknown { try { callback() } catch (error) { return error } throw new Error('callback given to getError ran without throwing an error') } export const component = { template: '
This is component
' } const parentA = createRoute({ name: 'parentA', path: '/parentA/[paramA]', }) const childA = createRoute({ parent: parentA, name: 'parentA.childA', path: '/childA/[?paramB]', }) const childB = createRoute({ parent: parentA, name: 'parentA.childB', path: '/childB/[paramD]', component, }) const grandChild = createRoute({ parent: childA, name: 'parentA.childA.grandChildA', path: '/[paramC]', component, }) export const routes = [ parentA, childA, childB, grandChild, createRoute({ name: 'parentB', path: '/parentB', component, }), createRoute({ name: 'parentC', path: '/', component, }), ] as const ================================================ FILE: src/utilities/trailingSlashes.spec.ts ================================================ import { describe, expect, test } from 'vitest' import { pathHasTrailingSlash, removeTrailingSlashesFromPath } from './trailingSlashes' describe('removeTrailingSlashesFromPath', () => { test('removes single trailing slash', () => { const url = '/foo/bar/' const response = removeTrailingSlashesFromPath(url) expect(response).toBe('/foo/bar') }) test('removes multiple trailing slashes', () => { const url = '/foo/bar///' const response = removeTrailingSlashesFromPath(url) expect(response).toBe('/foo/bar') }) test('does nothing if there are no trailing slashes', () => { const url = '/foo/bar' const response = removeTrailingSlashesFromPath(url) expect(response).toBe('/foo/bar') }) test('does not remove duplicate slashes in the middle of the string', () => { const url = '/foo//bar/' const response = removeTrailingSlashesFromPath(url) expect(response).toBe('/foo//bar') }) test('does not remove trailing slash that is the only character in the string', () => { const url = '/' const response = removeTrailingSlashesFromPath(url) expect(response).toBe('/') }) test('removes trailing slash from path of full URL', () => { const url = 'https://kitbag.dev/foo/' const response = removeTrailingSlashesFromPath(url) expect(response).toBe('https://kitbag.dev/foo') }) test('preserves root path for full URL (no trim)', () => { const url = 'https://kitbag.dev/' const response = removeTrailingSlashesFromPath(url) expect(response).toBe('https://kitbag.dev/') }) test('preserves query and hash when removing trailing slash from full URL', () => { const url = 'https://kitbag.dev/foo/?a=1#section' const response = removeTrailingSlashesFromPath(url) expect(response).toBe('https://kitbag.dev/foo?a=1#section') }) }) describe('hasTrailingSlashes', () => { test('returns true if the string has a trailing slash', () => { const url = '/foo/bar/' const response = pathHasTrailingSlash(url) expect(response).toBe(true) }) test('returns false if the string does not have a trailing slash', () => { const url = '/foo/bar' const response = pathHasTrailingSlash(url) expect(response).toBe(false) }) test('returns false when the only character is a leading slash', () => { const url = '/' const response = pathHasTrailingSlash(url) expect(response).toBe(false) }) test('returns true when full URL path has trailing slash', () => { const url = 'https://kitbag.dev/foo/' const response = pathHasTrailingSlash(url) expect(response).toBe(true) }) test('returns false when full URL path is root', () => { const url = 'https://kitbag.dev/' const response = pathHasTrailingSlash(url) expect(response).toBe(false) }) }) ================================================ FILE: src/utilities/trailingSlashes.ts ================================================ import { parseUrl, stringifyUrl } from '@/services/urlParser' /** * Path that starts with / and has one or more trailing slashes (excludes bare `/`). Capture group is path without trailing slashes. * */ const trailingSlashesRegex = /^(\/.+?)\/+$/ /** * Removes any trailing slashes from the path of a URL (e.g. `/foo/bar/` → `/foo/bar`). Path `/` is unchanged. */ export function removeTrailingSlashesFromPath(url: string): string { const { path, ...parts } = parseUrl(url) return stringifyUrl({ ...parts, path: path.replace(trailingSlashesRegex, '$1'), }) } /** * Returns true when the path part of the URL has trailing slashes that would be removed by removeTrailingSlashesFromUrl. */ export function pathHasTrailingSlash(url: string): boolean { const { path } = parseUrl(url) return trailingSlashesRegex.test(path) } ================================================ FILE: src/utilities/urlSearchParams.spec.ts ================================================ import { expect, test } from 'vitest' import { combineUrlSearchParams } from './urlSearchParams' test.each([ 'foo=bar', { foo: 'bar' }, [['foo', 'bar']], ])('given different constructor types, normalizes into URLSearchParams', (params) => { const response = combineUrlSearchParams(params, undefined) expect(Array.from(response.entries())).toMatchObject([ ['foo', 'bar'], ]) }) test('given duplicate keys, appends new entry', () => { const aParams = new URLSearchParams({ foo: 'foo' }) const bParams = new URLSearchParams({ foo: 'bar' }) const response = combineUrlSearchParams(aParams, bParams) expect(Array.from(response.entries())).toMatchObject([ ['foo', 'foo'], ['foo', 'bar'], ]) }) ================================================ FILE: src/utilities/urlSearchParams.ts ================================================ import { QuerySource } from '@/types/querySource' export function combineUrlSearchParams(...paramGroups: (URLSearchParams | QuerySource)[]): URLSearchParams { const combinedParams = new URLSearchParams() for (const params of paramGroups) { const paramsToAdd = new URLSearchParams(params) for (const [key, value] of paramsToAdd.entries()) { combinedParams.append(key, value) } } return combinedParams } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ESNext", "ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "paths": { "@/*": ["./src/*"],}, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src/**/*.ts", "src/**/*.vue"], "exclude": ["node_modules", "dist"] } ================================================ FILE: typedoc.mjs ================================================ export default { $schema: "https://typedoc.org/schema.json", entryPoints: ["./dist/kitbag-router.d.ts"], plugin: [ "typedoc-plugin-markdown", "typedoc-vitepress-theme", ], out: "./docs/api", docsRoot: "./docs/", tsconfig: "./typedoc.tsconfig.json", readme: "none", parametersFormat: "table", propertiesFormat: "table", hideBreadcrumbs: true, hidePageHeader: true, hideGroupHeadings: true, useCodeBlocks: true, disableSources: true, groupOrder: [ "Compositions", "Errors", "Interfaces", "Type Guards", "Types", "Utilities", "*", ], sidebar: { pretty: true, }, pageTitleTemplates: { member: (args) => `${args.group}: ${args.name}`, } } ================================================ FILE: typedoc.tsconfig.json ================================================ { "extends": "./tsconfig.json", "include": ["dist/kitbag-router.d.ts"], "exclude": ["node_modules"] } ================================================ FILE: vite.config.js ================================================ import { resolve } from 'path' import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import dts from 'vite-plugin-dts' export default defineConfig({ resolve: { alias: [ { find: '@', replacement: resolve(__dirname, 'src'), }, ], }, test: { projects: [ { extends: true, test: { name: 'browser', environment: 'happy-dom', include: ['src/**/*.browser.spec.ts'], }, }, { extends: true, test: { name: 'node', environment: 'node', include: ['src/**/*.spec.ts'], exclude: ['src/**/*.browser.spec.ts'], typecheck: { enabled: true, checker: 'vue-tsc', ignoreSourceErrors: true, tsconfig: './tsconfig.json', include: ['src/**/*.spec-d.ts'], }, }, }, ], }, build: { lib: { entry: resolve(__dirname, 'src/main.ts'), name: '@kitbag/router', fileName: 'kitbag-router', }, rollupOptions: { external: ['vue', 'zod', /^node:/], output: { globals: { vue: 'Vue', }, }, }, }, plugins: [ vue(), dts({ insertTypesEntry: true, }), ], })