├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __tests__ ├── init-usage.spec.ts ├── matcher.spec.ts ├── pre-init-usage.spec.ts └── tsconfig.json ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ ├── hash_router.spec.ts │ ├── history_base_router.spec.ts │ └── history_router.spec.ts ├── plugins │ └── index.js ├── support │ ├── commands.js │ └── index.js ├── test-app │ ├── public-hash │ │ └── index.html │ ├── public-path-base │ │ └── index.html │ ├── public-path │ │ └── index.html │ ├── rollup.config.js │ ├── src │ │ ├── App.svelte │ │ ├── global.d.ts │ │ ├── main.ts │ │ ├── package.ts │ │ ├── router.ts │ │ └── views │ │ │ ├── Dynamic.svelte │ │ │ ├── Home.svelte │ │ │ ├── HomeTransition.svelte │ │ │ ├── Null.svelte │ │ │ ├── Profile.svelte │ │ │ ├── ProfileBio.svelte │ │ │ └── ProfileWelcome.svelte │ └── tsconfig.json └── tsconfig.json ├── docs ├── api.md ├── comparison.md └── guide.md ├── examples ├── basic │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── rollup.config.js │ └── src │ │ ├── App.svelte │ │ ├── main.js │ │ ├── router.js │ │ └── routes │ │ ├── Home.svelte │ │ └── User.svelte ├── lazy-loading │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── rollup.config.js │ └── src │ │ ├── App.svelte │ │ ├── main.js │ │ ├── router.js │ │ └── routes │ │ ├── Home.svelte │ │ └── User.svelte └── react-router │ ├── README.md │ ├── package.json │ ├── public │ └── index.html │ ├── rollup.config.js │ └── src │ ├── App.svelte │ ├── main.js │ ├── react │ └── MyReactComponent.js │ ├── router.js │ └── routes │ ├── Home.svelte │ └── User.svelte ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── rollup.config.js ├── scripts ├── copy-svelte.js └── cy-serve.js ├── src ├── components │ ├── Link.svelte │ └── RouterView.svelte ├── global.ts ├── index.ts ├── location-change-shim.ts ├── matcher.ts ├── router │ ├── base.ts │ ├── hash-router.ts │ └── path-router.ts ├── types.ts └── util.ts ├── svelte.config.js ├── tsconfig.base.json └── tsconfig.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '**.md' 7 | - 'examples/**' 8 | pull_request: 9 | paths-ignore: 10 | - '**.md' 11 | - 'examples/**' 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Install pnpm 22 | run: npm i -g pnpm@6 23 | 24 | - name: Setup node 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: 16 28 | cache: 'pnpm' 29 | cache-dependency-path: '**/pnpm-lock.yaml' 30 | 31 | - name: Cache Cypress 32 | uses: actions/cache@v2 33 | with: 34 | path: ~/.cache/Cypress 35 | key: cypress-cache-ubuntu-${{ hashFiles('**/pnpm-lock.yaml') }} 36 | 37 | - name: Install dependencies 38 | run: pnpm i 39 | 40 | - name: Lint 41 | run: pnpm lint 42 | 43 | - name: Unit test 44 | run: pnpm test:unit 45 | 46 | - name: End-to-end test 47 | uses: cypress-io/github-action@v2 48 | with: 49 | headless: true 50 | start: pnpm cy:setup 51 | wait-on: 'http://localhost:10003' 52 | install: false 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | 56 | - name: Upload Cypress screenshots 57 | uses: actions/upload-artifact@v1 58 | if: failure() 59 | with: 60 | name: cypress-screenshots 61 | path: cypress/screenshots 62 | 63 | - name: Upload Cypress videos 64 | uses: actions/upload-artifact@v1 65 | if: failure() 66 | with: 67 | name: cypress-videos 68 | path: cypress/videos 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | **/build 4 | .vscode 5 | cypress/screenshots 6 | cypress/videos 7 | examples/**/package-lock.json 8 | examples/**/yarn.lock 9 | examples/**/pnpm-lock.yaml -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | **/build 4 | .vscode 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "proseWrap": "never", 6 | "plugins": ["./node_modules/prettier-plugin-svelte"] 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.0 - 2021-09-10 4 | 5 | ### Added 6 | 7 | - Use location API for hash navigation ([#15](https://github.com/bluwy/svelte-router/pull/15)) 8 | 9 | ## 0.4.1 - 2021-01-31 10 | 11 | ### Fixed 12 | 13 | - Don't remount component when enter nested routes (again) 14 | 15 | ## 0.4.0 - 2021-01-30 16 | 17 | ### Fixed 18 | 19 | - Don't remount component when enter nested routes 20 | 21 | ## 0.3.2 - 2020-10-11 22 | 23 | ### Fixed 24 | 25 | - Keep `navigate` and `createLink` reference when initializing router 26 | 27 | ## 0.3.1 - 2020-09-22 28 | 29 | ### Added 30 | 31 | - Support component dynamic import 32 | 33 | ## 0.3.0 - 2020-09-04 34 | 35 | ### Added 36 | 37 | - Improve `component` typing 38 | 39 | ### Changed 40 | 41 | - `route` only update if url change 42 | - Replace `initRouter` with `initHashRouter` and `initPathRouter` to improve tree-shaking 43 | 44 | ### Removed 45 | 46 | - Remove `RedirectOption` type export 47 | 48 | ## 0.2.1 - 2020-09-01 49 | 50 | ### Fixed 51 | 52 | - `` will work properly with special key clicks, e.g. ctrl-click 53 | 54 | ## 0.2.0 - 2020-08-30 55 | 56 | ### Added 57 | 58 | - Add `createLink` function to enable custom link creation 59 | - Format URL on page load 60 | 61 | ### Changed 62 | 63 | - Rename `history` mode to `path` 64 | - All router options need to be defined 65 | - All public APIs, except for `route`, will now throw error if called before `initRouter` 66 | - `initRouter` will throw error if called more than once 67 | - `LocationInput`'s `path`, `search` and `hash` will treat empty string as undefined 68 | 69 | ### Removed 70 | 71 | - Remove `link` action, use `createLink` instead 72 | 73 | ### Fixed 74 | 75 | - Fix synchronous redirect rendering 76 | 77 | ## 0.1.2 - 2020-08-26 78 | 79 | ### Fixed 80 | 81 | - Route store is always defined even before `initRouter` 82 | - `navigate` will not throw error if called before `initRouter` 83 | - Fix base path detection 84 | 85 | ## 0.1.1 - 2020-08-25 86 | 87 | ### Fixed 88 | 89 | - Fix unexpected slot "default" warning 90 | 91 | ## 0.1.0 - 2020-08-25 92 | 93 | Initial release 94 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Svelte Router 2 | 3 | Thanks for your interest in making `svelte-router` better! 4 | 5 | ## Issues and Feature Requests 6 | 7 | [Open an issue](https://github.com/bluwy/svelte-router/issues/new/choose) for any bug reports, unexpected behaviors, or feature requests. If possible, provide a reproduction repo to help us in resolving the issues. 8 | 9 | ## Documentation Updates 10 | 11 | All are welcomed to improve the documentation. The docs are currently located in [`/docs`](./docs). Do note that the docs follow the [Vue Docs Writing Guide](https://v3.vuejs.org/guide/contributing/writing-guide.html). 12 | 13 | ## Code Contribution 14 | 15 | When sending a pull request, make sure the changes are properly formatted and tested so it can be merged. See the [code formatting](#code-formatting) and [testing](#testing) sections below for more information. 16 | 17 | Below is the general development workflow: 18 | 19 | ### Local Development 20 | 21 | 1. Clone the repo 22 | 2. Run `pnpm install` to install dependencies 23 | 3. Run `pnpm cy:setup` to manually test the router 24 | 25 | This will start up 3 servers at: 26 | 27 | - http://localhost:10001 - `hash` mode 28 | - http://localhost:10002 - `path` mode 29 | - http://localhost:10003 - `path` mode with base tag 30 | 31 | ### Code Formatting 32 | 33 | The library uses [Prettier](https://prettier.io/) to format the code, including HTML, JS, TS and Svelte files. 34 | 35 | Code will be auto-formatted per git commits using [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged). 36 | 37 | > NOTE: Markdown files are temporarily not formatted until [prettier-plugin-svelte#129](https://github.com/sveltejs/prettier-plugin-svelte/issues/129) is fixed. 38 | 39 | ### Testing 40 | 41 | The library performs unit testing (with [Jest](https://jestjs.io)) and e2e testing (with [Cypress](https://cypress.io)). 42 | 43 | - Run `pnpm test:unit` for unit tests 44 | - Run `pnpm test:e2e` for e2e tests 45 | 46 | When pushing or sending a pull request to this repo, a GitHub Actions workflow will run both unit and e2e tests. 47 | 48 | ### Publishing 49 | 50 | There's currently no automated publishing workflow. For now, it is done locally by [bluwy](https://github.com/bluwy). 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bjorn Lu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Router 2 | 3 | 4 | [![package version](https://img.shields.io/npm/v/@bjornlu/svelte-router)](https://www.npmjs.com/package/@bjornlu/svelte-router) 5 | [![npm downloads](https://img.shields.io/npm/dm/@bjornlu/svelte-router)](https://www.npmjs.com/package/@bjornlu/svelte-router) 6 | [![ci](https://github.com/bluwy/svelte-router/workflows/CI/badge.svg?event=push)](https://github.com/bluwy/svelte-router/actions) 7 | 8 | > Before you start, I'd highly recommend checking out [SvelteKit](https://github.com/sveltejs/kit) first. It has a flexible filesystem-based router officially supported by the Svelte team. 9 | 10 | An easy-to-use SPA router for Svelte. 11 | 12 | [**Comparison with other routers**](./docs/comparison.md) 13 | 14 | ## Features 15 | 16 | - Super simple API 17 | - Support `hash` and `path` based navigation 18 | - Nested routes 19 | - Redirects and navigation guards (with async support) 20 | - Lazy loading routes 21 | - Auto `base` tag navigation 22 | 23 | ## Not Supported 24 | 25 | - Server-side rendering (SSR) - Use [SvelteKit](https://github.com/sveltejs/kit) instead 26 | - Relative navigations - Use absolute path and [dynamic syntax](./docs/guide.md#dynamic-syntax) instead 27 | 28 | ## Quick Start 29 | 30 | Install [`@bjornlu/svelte-router`](https://www.npmjs.com/package/@bjornlu/svelte-router): 31 | 32 | ```bash 33 | $ npm install @bjornlu/svelte-router 34 | ``` 35 | 36 | Define routes: 37 | 38 | ```js 39 | // main.js 40 | 41 | import { initPathRouter } from '@bjornlu/svelte-router' 42 | import App from './App.svelte' 43 | import Home from './Home.svelte' 44 | 45 | // Use `initHashRouter` for hash mode 46 | initPathRouter([{ path: '/', component: Home }]) 47 | 48 | const app = new App({ 49 | target: document.body 50 | }) 51 | 52 | export default app 53 | ``` 54 | 55 | Render routes and links: 56 | 57 | ```svelte 58 | 59 | 60 | 63 | 64 |
65 | 68 | 69 |
70 | ``` 71 | 72 | Done! 73 | 74 | ## Documentation 75 | 76 | Ready to learn more? The documentation is split into two parts: 77 | 78 | - [Guide](./docs/guide.md): Covers common usage to advanced topics 79 | - [API reference](./docs/api.md): Covers the structure of the API 80 | 81 | ## Examples 82 | 83 | - [Basic](./examples/basic): Basic router usage 84 | - [Lazy loading](./examples/lazy-loading): Lazy loading setup example 85 | 86 | ## Contributing 87 | 88 | All contributions are welcomed. Check out [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. 89 | 90 | ## License 91 | 92 | MIT 93 | -------------------------------------------------------------------------------- /__tests__/init-usage.spec.ts: -------------------------------------------------------------------------------- 1 | import { initHashRouter, initPathRouter } from '../src' 2 | 3 | describe('init usage', () => { 4 | it('should throw if initRouter called more than once', () => { 5 | const call = () => { 6 | initHashRouter([]) 7 | initPathRouter([]) 8 | } 9 | expect(call).toThrow() 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/matcher.spec.ts: -------------------------------------------------------------------------------- 1 | import { RouteMatcher } from '../src/matcher' 2 | 3 | describe('matcher', () => { 4 | it('should match a route', () => { 5 | const matcher = new RouteMatcher([{ path: '/foo' }, { path: '/bar' }]) 6 | const route = matcher.matchRoute('/bar') 7 | expect(route?.matched[0].path).toBe('/bar') 8 | }) 9 | 10 | it('should match the same sequence of route records', () => { 11 | const matcher = new RouteMatcher([ 12 | { 13 | path: '/foo', 14 | children: [ 15 | { 16 | path: '/bar', 17 | children: [{ path: '/baz' }] 18 | } 19 | ] 20 | } 21 | ]) 22 | const route = matcher.matchRoute('/foo/bar/baz') 23 | expect(route?.matched.length).toBe(3) 24 | expect(route?.matched[0].path).toBe('/foo') 25 | expect(route?.matched[1].path).toBe('/bar') 26 | expect(route?.matched[2].path).toBe('/baz') 27 | }) 28 | 29 | it('should match nested routes', () => { 30 | const matcher = new RouteMatcher([ 31 | { 32 | path: '/foo', 33 | children: [{ path: '/bar' }] 34 | } 35 | ]) 36 | const route1 = matcher.matchRoute('/foo/bar') 37 | const route2 = matcher.matchRoute('/foo') 38 | expect(route1?.matched[1].path).toBe('/bar') 39 | expect(route2?.matched.length).toBe(1) 40 | expect(route2?.matched[0].path).toBe('/foo') 41 | }) 42 | 43 | it('should be tolerant to oddly named paths', () => { 44 | const matcher = new RouteMatcher([ 45 | { 46 | path: 'foo', 47 | children: [{ path: 'bar/' }, { path: 'baz' }, { path: '*' }] 48 | } 49 | ]) 50 | const route1 = matcher.matchRoute('/foo/bar') 51 | const route2 = matcher.matchRoute('/foo/baz') 52 | const route3 = matcher.matchRoute('/foo/bla/') 53 | expect(route1?.matched[1].path).toBe('bar/') 54 | expect(route2?.matched[1].path).toBe('baz') 55 | expect(route3?.params.wild).toBe('/bla') 56 | }) 57 | 58 | it('should parse named parameters', () => { 59 | const matcher = new RouteMatcher([{ path: '/foo/:id' }]) 60 | const route = matcher.matchRoute('/foo/abc') 61 | expect(route?.params.id).toBe('abc') 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /__tests__/pre-init-usage.spec.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'svelte/store' 2 | import { route, navigate, createLink } from '../src' 3 | 4 | describe('API access before router initialization', function () { 5 | it('should have a default route store value', function () { 6 | const $route = get(route) 7 | expect(typeof $route === 'object').toBe(true) 8 | }) 9 | 10 | it('should throw error if navigate', function () { 11 | expect(() => navigate(0)).toThrow() 12 | }) 13 | 14 | it('should throw error if createLink', function () { 15 | expect(() => createLink({})).toThrow() 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "types": ["jest", "svelte"] 5 | }, 6 | "include": ["**/*.spec.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "vjxpm8", 3 | "videoUploadOnPasses": false 4 | } 5 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/hash_router.spec.ts: -------------------------------------------------------------------------------- 1 | describe('hash router', { baseUrl: 'http://localhost:10001' }, () => { 2 | it('should render initial route', () => { 3 | cy.visit('#/') 4 | cy.get('h2').contains('Home').should('exist') 5 | }) 6 | 7 | it('should change hash on route change', () => { 8 | cy.visit('#/') 9 | cy.get('a').contains('Null').click() 10 | cy.hash().should('eq', '#/null') 11 | cy.get('h2').contains('404').should('exist') 12 | }) 13 | 14 | it('should handle nested routes', () => { 15 | cy.visit('#/profile/123') 16 | cy.get('h2').contains('Profile').should('exist') 17 | }) 18 | 19 | it('should handle programmatic navigation', () => { 20 | cy.visit('#/') 21 | cy.get('#profile-id').type('123') 22 | cy.get('#profile-button').click() 23 | cy.contains('Your ID is 123').should('exist') 24 | cy.hash().should('eq', '#/profile/123/welcome') 25 | }) 26 | 27 | it('should handle param shorthand paths', () => { 28 | cy.visit('#/profile/123') 29 | cy.get('a').contains('Welcome').click() 30 | cy.contains('Your ID is 123').should('exist') 31 | cy.hash().should('eq', '#/profile/123/welcome') 32 | }) 33 | 34 | it('should display correct href for link component', () => { 35 | cy.visit('#/profile/123') 36 | cy.get('a') 37 | .contains('Welcome') 38 | .should('have.attr', 'href') 39 | .and('eq', '#/profile/123/welcome') 40 | }) 41 | 42 | it('should redirect', () => { 43 | cy.visit('#/secret') 44 | cy.hash().should('eq', '#/') 45 | cy.get('h2').contains('Home').should('exist') 46 | }) 47 | 48 | it('should not redirect if undefined', () => { 49 | cy.visit('#/profile/123/bio') 50 | cy.get('h3').contains('Bio').should('exist') 51 | }) 52 | 53 | it('should dynamically import a route', () => { 54 | cy.visit('#/') 55 | cy.get('a').contains('Dynamic').click() 56 | cy.contains('Dynamic import').should('exist') 57 | }) 58 | 59 | it('should handle nested route transitions', () => { 60 | cy.visit('#/') 61 | cy.get('a').contains('Transition').click() 62 | cy.contains('Transition test').should('exist') 63 | cy.get('a').contains('Home').click() 64 | cy.contains('Transition test').should('not.exist') 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /cypress/integration/history_base_router.spec.ts: -------------------------------------------------------------------------------- 1 | describe('history base router', { baseUrl: 'http://localhost:10003' }, () => { 2 | it('should render initial route', () => { 3 | cy.visit('/test') 4 | cy.get('h2').contains('Home').should('exist') 5 | }) 6 | 7 | it('should change pathname on route change', () => { 8 | cy.visit('/test') 9 | cy.get('a').contains('Null').click() 10 | cy.location('pathname').should('eq', '/test/null') 11 | cy.get('h2').contains('404').should('exist') 12 | }) 13 | 14 | it('should handle nested routes', () => { 15 | cy.visit('/test/profile/123') 16 | cy.get('h2').contains('Profile').should('exist') 17 | }) 18 | 19 | it('should handle programmatic navigation', () => { 20 | cy.visit('/test') 21 | cy.get('#profile-id').type('123') 22 | cy.get('#profile-button').click() 23 | cy.contains('Your ID is 123').should('exist') 24 | cy.location('pathname').should('eq', '/test/profile/123/welcome') 25 | }) 26 | 27 | it('should handle param shorthand paths', () => { 28 | cy.visit('/test/profile/123') 29 | cy.get('a').contains('Welcome').click() 30 | cy.contains('Your ID is 123').should('exist') 31 | cy.location('pathname').should('eq', '/test/profile/123/welcome') 32 | }) 33 | 34 | it('should display correct href for link component', () => { 35 | cy.visit('/test/profile/123') 36 | cy.get('a') 37 | .contains('Welcome') 38 | .should('have.attr', 'href') 39 | .and('eq', '/test/profile/123/welcome') 40 | }) 41 | 42 | it('should redirect', () => { 43 | cy.visit('/test/secret') 44 | cy.location('pathname').should('eq', '/test') 45 | cy.get('h2').contains('Home').should('exist') 46 | }) 47 | 48 | it('should not redirect if undefined', () => { 49 | cy.visit('/test/profile/123/bio') 50 | cy.get('h3').contains('Bio').should('exist') 51 | }) 52 | 53 | it('should dynamically import a route', () => { 54 | cy.visit('/test') 55 | cy.get('a').contains('Dynamic').click() 56 | cy.contains('Dynamic import').should('exist') 57 | }) 58 | 59 | it('should handle nested route transitions', () => { 60 | cy.visit('/test') 61 | cy.get('a').contains('Transition').click() 62 | cy.contains('Transition test').should('exist') 63 | cy.get('a').contains('Home').click() 64 | cy.contains('Transition test').should('not.exist') 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /cypress/integration/history_router.spec.ts: -------------------------------------------------------------------------------- 1 | describe('history router', { baseUrl: 'http://localhost:10002' }, () => { 2 | it('should render initial route', () => { 3 | cy.visit('/') 4 | cy.get('h2').contains('Home').should('exist') 5 | }) 6 | 7 | it('should change pathname on route change', () => { 8 | cy.visit('/') 9 | cy.get('a').contains('Null').click() 10 | cy.location('pathname').should('eq', '/null') 11 | cy.get('h2').contains('404').should('exist') 12 | }) 13 | 14 | it('should handle nested routes', () => { 15 | cy.visit('/profile/123') 16 | cy.get('h2').contains('Profile').should('exist') 17 | }) 18 | 19 | it('should handle programmatic navigation', () => { 20 | cy.visit('/') 21 | cy.get('#profile-id').type('123') 22 | cy.get('#profile-button').click() 23 | cy.contains('Your ID is 123').should('exist') 24 | cy.location('pathname').should('eq', '/profile/123/welcome') 25 | }) 26 | 27 | it('should handle param shorthand paths', () => { 28 | cy.visit('/profile/123') 29 | cy.get('a').contains('Welcome').click() 30 | cy.contains('Your ID is 123').should('exist') 31 | cy.location('pathname').should('eq', '/profile/123/welcome') 32 | }) 33 | 34 | it('should display correct href for link component', () => { 35 | cy.visit('/profile/123') 36 | cy.get('a') 37 | .contains('Welcome') 38 | .should('have.attr', 'href') 39 | .and('eq', '/profile/123/welcome') 40 | }) 41 | 42 | it('should redirect', () => { 43 | cy.visit('/secret') 44 | cy.location('pathname').should('eq', '/') 45 | cy.get('h2').contains('Home').should('exist') 46 | }) 47 | 48 | it('should not redirect if undefined', () => { 49 | cy.visit('/profile/123/bio') 50 | cy.get('h3').contains('Bio').should('exist') 51 | }) 52 | 53 | it('should dynamically import a route', () => { 54 | cy.visit('/') 55 | cy.get('a').contains('Dynamic').click() 56 | cy.contains('Dynamic import').should('exist') 57 | }) 58 | 59 | it('should handle nested route transitions', () => { 60 | cy.visit('/') 61 | cy.get('a').contains('Transition').click() 62 | cy.contains('Transition test').should('exist') 63 | cy.get('a').contains('Home').click() 64 | cy.contains('Transition test').should('not.exist') 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/test-app/public-hash/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Svelte Router Hash Test 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /cypress/test-app/public-path-base/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Svelte Router Path Base Test 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /cypress/test-app/public-path/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Svelte Router Path Test 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /cypress/test-app/rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import replace from '@rollup/plugin-replace' 4 | import typescript from '@rollup/plugin-typescript' 5 | import svelte from 'rollup-plugin-svelte' 6 | 7 | const svelteConfig = require('../../svelte.config') 8 | 9 | const p = (...args) => path.resolve(__dirname, ...args) 10 | 11 | const createConfig = (routerMode, publicPath) => ({ 12 | input: p('src/main.ts'), 13 | output: { 14 | sourcemap: true, 15 | dir: p(publicPath, 'build'), 16 | format: 'es', 17 | name: 'app' 18 | }, 19 | plugins: [ 20 | svelte({ 21 | dev: true, 22 | preprocess: svelteConfig.preprocess, 23 | css: (v) => v.write('bundle.css') 24 | }), 25 | resolve(), 26 | replace({ 27 | preventAssignment: true, 28 | values: { 29 | __ROUTER_MODE__: JSON.stringify(routerMode), 30 | 'process.env.NODE_ENV': JSON.stringify('development') 31 | } 32 | }), 33 | typescript({ 34 | tsconfig: p('tsconfig.json') 35 | }) 36 | ] 37 | }) 38 | 39 | export default [ 40 | createConfig('hash', 'public-hash'), 41 | createConfig('path', 'public-path'), 42 | createConfig('path', 'public-path-base') 43 | ] 44 | -------------------------------------------------------------------------------- /cypress/test-app/src/App.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 | Home 10 | Null 11 | Dynamic 12 | Transition 13 | Home replace 14 |
15 |
16 | 17 | 23 |
24 |

Svelte Routing Test

25 | 26 |
27 | -------------------------------------------------------------------------------- /cypress/test-app/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const __ROUTER_MODE__: 'hash' | 'path' 2 | -------------------------------------------------------------------------------- /cypress/test-app/src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './App.svelte' 2 | import './router' 3 | 4 | const app = new App({ 5 | target: document.getElementById('app') as Element 6 | }) 7 | 8 | export default app 9 | -------------------------------------------------------------------------------- /cypress/test-app/src/package.ts: -------------------------------------------------------------------------------- 1 | // Re-export for convenience 2 | export * from '../../../src' 3 | -------------------------------------------------------------------------------- /cypress/test-app/src/router.ts: -------------------------------------------------------------------------------- 1 | import { initHashRouter, initPathRouter } from './package' 2 | import Home from './views/Home.svelte' 3 | import HomeTransition from './views/HomeTransition.svelte' 4 | import Profile from './views/Profile.svelte' 5 | import ProfileWelcome from './views/ProfileWelcome.svelte' 6 | import ProfileBio from './views/ProfileBio.svelte' 7 | import Null from './views/Null.svelte' 8 | 9 | const initRouter = __ROUTER_MODE__ === 'hash' ? initHashRouter : initPathRouter 10 | 11 | initRouter([ 12 | { 13 | path: '/', 14 | component: Home, 15 | children: [ 16 | { 17 | path: '/transition', 18 | component: HomeTransition 19 | } 20 | ] 21 | }, 22 | { 23 | path: '/profile/:id', 24 | component: Profile, 25 | children: [ 26 | { 27 | path: '/welcome', 28 | component: ProfileWelcome 29 | }, 30 | { 31 | path: '/bio', 32 | component: ProfileBio, 33 | redirect: () => 34 | new Promise((resolve) => { 35 | setTimeout(() => resolve(undefined), 1000) 36 | }) 37 | } 38 | ] 39 | }, 40 | { 41 | path: '/secret', 42 | redirect: '/' 43 | }, 44 | { 45 | path: '/dynamic', 46 | component: () => import('./views/Dynamic.svelte') 47 | }, 48 | { 49 | path: '/*', 50 | component: Null 51 | } 52 | ]) 53 | -------------------------------------------------------------------------------- /cypress/test-app/src/views/Dynamic.svelte: -------------------------------------------------------------------------------- 1 |

Dynamic import

2 | -------------------------------------------------------------------------------- /cypress/test-app/src/views/Home.svelte: -------------------------------------------------------------------------------- 1 |

Home

2 | 3 | -------------------------------------------------------------------------------- /cypress/test-app/src/views/HomeTransition.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
Transition test
6 | 7 | 12 | -------------------------------------------------------------------------------- /cypress/test-app/src/views/Null.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |

404

7 |
8 | -------------------------------------------------------------------------------- /cypress/test-app/src/views/Profile.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |

Profile

8 |

Your ID is {id}

9 | 10 |
11 | Welcome 12 | Bio 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /cypress/test-app/src/views/ProfileBio.svelte: -------------------------------------------------------------------------------- 1 |

Bio

2 | -------------------------------------------------------------------------------- /cypress/test-app/src/views/ProfileWelcome.svelte: -------------------------------------------------------------------------------- 1 |

Welcome

2 | -------------------------------------------------------------------------------- /cypress/test-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2020" 5 | }, 6 | "include": ["**/*.ts", "../../src"], 7 | "exclude": ["**/build"] 8 | } 9 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "lib": ["es5", "dom"], 7 | "types": ["cypress"] 8 | }, 9 | "include": ["**/*.ts"], 10 | "exclude": ["test-app"] 11 | } 12 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## Table of Contents 4 | 5 | - [``](#routerview-) 6 | - [``](#link-) 7 | - [`initHashRouter(routes: RouteRecord[])`](#inithashrouterroutes-routerecord) 8 | - [`initPathRouter(routes: RouteRecord[])`](#initpathrouterroutes-routerecord) 9 | - [`navigate()`](#navigate) 10 | - [`navigate(to: number)`](#navigateto-number) 11 | - [`navigate(to: string | LocationInput, replace?: boolean)`](#navigateto-string--locationinput-replace-boolean) 12 | - [`$route`](#route) 13 | - [`createLink(to: string | LocationInput)`](#createlinkto-string--locationinput) 14 | 15 | ## `` 16 | 17 | Renders the routes. No additional props. 18 | 19 | ## `` 20 | 21 | 22 | | Prop | Type | Default | Description | 23 | |---------|-------------------------|---------|------------------------------------------| 24 | | to | string \| LocationInput | | Target route | 25 | | replace | boolean | `false` | Replace current route instead of pushing | 26 | 27 | - Renders an anchor tag 28 | - Adds `aria-current="page"` when link exactly matches 29 | - Display correct `href` with resolved dynamic segments, base path, and hash prepends, e.g. `/user/:id` => `#/user/foo` 30 | - Adds active class names based on `to` and current route path: 31 | - `link-active` when link partially matches, e.g. `/foo` matches `/foo/bar` 32 | - `link-exact-active` when link exactly matches, e.g. `/foo` matches `/foo` 33 | 34 | ## `initHashRouter(routes: RouteRecord[])` 35 | 36 | Initializes a `hash` mode router for the app. 37 | 38 | ## `initPathRouter(routes: RouteRecord[])` 39 | 40 | Initializes a `path` mode router for the app. 41 | 42 | ## `navigate()` 43 | 44 | `navigate()` has two function signatures: 45 | 46 | ### `navigate(to: number)` 47 | 48 | Navigate using an offset in the current history. Works the same way as [`history.go`](https://developer.mozilla.org/en-US/docs/Web/API/History/go). 49 | 50 | ### `navigate(to: string | LocationInput, replace?: boolean)` 51 | 52 | Navigate to a route using a path string or a location descriptor object. `string` and `LocationInput` are semantically equal and are just different ways to express the same route. For example: 53 | 54 | ```js 55 | '/foo?key=value#top' 56 | 57 | // same as 58 | 59 | { 60 | path: '/foo', 61 | search: { key: 'value' }, 62 | hash: '#top' 63 | } 64 | ``` 65 | 66 | ## `$route` 67 | 68 | A readable store that contains the current route information. 69 | 70 | 71 | | Property | Type | Example | Description | 72 | |----------|------------------------|-----------------|-----------------------------------------------------------| 73 | | path | string | `'/user/foo'` | The route path without search and hash | 74 | | params | Record | `{ id: 'foo' }` | The parsed dynamic segments of the path, e.g. `/user/:id` | 75 | | search | URLSearchParams | | The path search parsed with URLSearchParams | 76 | | hash | string | `'#hey'` | The path hash with leading `#`. Empty string if no hash. | 77 | | matched | RouteRecord[] | | The array of route records that matches the current route | 78 | 79 | ## `createLink(to: string | LocationInput)` 80 | 81 | Returns a readable store that contains the given link's information. 82 | 83 | 84 | | Property | Type | Example | Description | 85 | |---------------|---------|----------------|-----------------------------------------------------------------------| 86 | | href | string | `'#/user/foo'` | The path with resolved dynamic segments, base path, and hash prepends | 87 | | isActive | boolean | `false` | Whether the link is partially matching the current path | 88 | | isExactActive | boolean | `false` | Whether the link is exactly matching the current path | 89 | -------------------------------------------------------------------------------- /docs/comparison.md: -------------------------------------------------------------------------------- 1 | # Comparison 2 | 3 | _Last updated: 22 September 2020_ 4 | 5 | ## Key Difference 6 | 7 | One main feature that most routers don't have is a global route store, that is information about the current `path`, `search`, `hash`, and `params`. Most routers pass this data via props or via [context](https://svelte.dev/tutorial/context-api) which cannot be used outside of components. 8 | 9 | Besides that, `svelte-router` also has a unique [dynamic syntax](./guide.md#dynamic-syntax), which makes it clear where a link navigates to. For example, `navigate('/user/:id/profile')` instead of `navigate('../../profile')`. 10 | 11 | For a more fine-grained control, `svelte-router` exposes [`createLink()`](./guide.md#link-information), which is a nice low-level utility to retrieve link information without actually creating one. 12 | 13 | ## General Difference 14 | 15 | A non-exhaustive list of differences between popular routers: 16 | 17 | 18 | | | [@bjornlu/svelte-router](https://github.com/bluwy/svelte-router) | [svelte-routing](https://github.com/EmilTholin/svelte-routing) | [svelte-spa-router](https://github.com/ItalyPaleAle/svelte-spa-router) | [svelte-router-spa](https://github.com/jorgegorka/svelte-router) | [routify](https://github.com/roxiness/routify) | 19 | |---------------------------------|------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 20 | | Route definition style | JS object | Component | JS object. Nested routes are separated into different objects. | JS object | File-system | 21 | | Router mode | Hash and path | Path | Hash | Path | Hash and path | 22 | | Access route data | Access global route store anywhere | Passed by prop and `let:param`. [A workaround](https://github.com/EmilTholin/svelte-routing/issues/41#issuecomment-503462045) can be used for descendant components. | `params` is passed by props. `path` and `search` can be accessed anywhere. | Passed by props only | Exposes global [search](https://routify.dev/docs/helpers#params) store. [`path`]((https://routify.dev/docs/helpers#url)) and [`params`](https://routify.dev/docs/helpers#params) are passed with context. | 23 | | Redirects and navigation guards | Built-in with async support | Manual redirect in components | Built-in with async support | Built-in but [no async support](https://github.com/jorgegorka/svelte-router/issues/20) | Manual redirect in components | 24 | | SSR support | :x: | :heavy_check_mark: | :x: | :x: | :x: | 25 | | Relative navigation | :x: | :x: | :x: | :x: | :heavy_check_mark: | 26 | 27 | ## Honorable Mentions 28 | 29 | Table so large, there's no space for these great routers: 30 | 31 | - [yrv](https://github.com/pateketrueke/yrv) 32 | - [svelte-navigator](https://github.com/mefechoel/svelte-navigator) 33 | - [tinro](https://github.com/AlexxNB/tinro) 34 | -------------------------------------------------------------------------------- /docs/guide.md: -------------------------------------------------------------------------------- 1 | # Guide 2 | 3 | Here contains all you need to know about `svelte-router`! 4 | 5 | If you haven't setup `svelte-router`, check out the [quick start](../README.md#quick-start)! 6 | 7 | ## Table of Contents 8 | 9 | - [Router Modes](#router-modes) 10 | - [Dynamic Route Matching](#dynamic-route-matching) 11 | - [Catch-all Route](#catch-all-route) 12 | - [Matching Priority](#matching-priority) 13 | - [Nested Routes](#nested-routes) 14 | - [Route Navigation](#route-navigation) 15 | - [Navigate with Links](#navigate-with-links) 16 | - [Programmatic Navigation](#programmatic-navigation) 17 | - [Dynamic Syntax](#dynamic-syntax) 18 | - [Route Information](#route-information) 19 | - [Link Information](#link-information) 20 | - [Redirects and Navigation Guards](#redirects-and-navigation-guards) 21 | - [Lazy Loading Routes](#lazy-loading-routes) 22 | - [Transitions](#transitions) 23 | - [Base Path](#base-path) 24 | 25 | ## Router Modes 26 | 27 | `svelte-router` supports both `hash` and `path` mode routing. `hash` mode works by manipulating the URL hash, while `path` mode works by manipulating the URL path: 28 | 29 | 30 | | Mode | Initialize API | Example URL | 31 | |------|--------------------|--------------------------------| 32 | | hash | `initHashRouter()` | https://example.com/#/user/foo | 33 | | path | `initPathRouter()` | https://example.com/user/foo | 34 | 35 | Do note that `path` mode requires additional configuration on the server-side to serve a single HTML file for all URL paths. For example, the server should serve http://example.com/index.html when the user visits https://example.com/user/foo. 36 | 37 | ## Dynamic Route Matching 38 | 39 | Sometimes a route path cannot be known ahead of time. For example, a user ID may be used in the route to show its corresponding information. 40 | 41 | In `svelte-router`, we can use a dynamic segment to capture the variable: 42 | 43 | ```js 44 | [ 45 | // Dynamic segment starts with ":" and the following text is its variable name 46 | { path: '/user/:id', component: User } 47 | ] 48 | ``` 49 | 50 | Now when the user visits `/user/foo`, it will match and render the `User` component. The `id` variable can then be accessed through `$route.params`: 51 | 52 | ```svelte 53 | 54 | 55 | 60 | ``` 61 | 62 | ### Catch-all Route 63 | 64 | When no routes can be matched, we can use a wildcard as a safety net to render a fallback component: 65 | 66 | ```js 67 | [ 68 | { path: '/user', component: User }, 69 | // Render fallback if route isn't `/user` 70 | { path: '/*', component: Fallback } 71 | ] 72 | ``` 73 | 74 | When the catch-all route is matched, `$route.params.wild` will also return the path that matched the wildcard. For example when navigating to `/non-exist`, `$route.params.wild` returns `/non-exist`. 75 | 76 | ### Matching Priority 77 | 78 | `svelte-router` matches the defined routes sequentially from the first to last. For example: 79 | 80 | ```js 81 | [ 82 | { path: '/:foo', component: Foo }, 83 | { path: '/user', component: User } 84 | ] 85 | ``` 86 | 87 | When the URL is `/user`, it will match `/:foo` instead since it satisfies the pattern first. Hence, routes with higher specificity should be placed before routes with lower specificity, such as dynamic segments and wildcards. 88 | 89 | ## Nested Routes 90 | 91 | Similar to the hierarchical nature of DOM elements, segments of the URL path may also represent the hierarchy of the routes and its components. 92 | 93 | In `svelte-router`, nested routes can be configured with the `children` property: 94 | 95 | ```js 96 | [ 97 | { 98 | path: '/user/:id', 99 | component: User, 100 | children: [ 101 | { path: '/profile', component: UserProfile }, 102 | { path: '/posts', component: UserPosts } 103 | ] 104 | } 105 | ] 106 | ``` 107 | 108 | For the `User` component to render its children, a `` tag must be present: 109 | 110 | ```svelte 111 | 112 | 113 |

User

114 |
115 |

Children

116 | 117 |
118 | ``` 119 | 120 | Now when the user visits `/user/foo/profile`, it will render `User` and `UserProfile`. 121 | 122 | ## Route Navigation 123 | 124 | `svelte-router` supports both declarative and programmatic ways of navigating between routes, that is with the `` component and the `navigate()` function. 125 | 126 | ### Navigate with Links 127 | 128 | The `` component has a `to` prop which accepts either a string path or location descriptor object. For example: 129 | 130 | ```svelte 131 | 134 | 135 | 136 | ... 137 | 138 | 139 | ... 140 | 141 | 142 | ... 143 | 144 | 145 | ... 146 | ``` 147 | 148 | ### Programmatic Navigation 149 | 150 | Sometimes manual navigation is needed to respond to a user action, like a form submission. The `navigate()` function can be used to do so: 151 | 152 | ```js 153 | import { navigate } from '@bjornlu/svelte-router' 154 | 155 | // Navigate to `/` 156 | navigate('/') 157 | 158 | // Navigate to `/login?foo=bar` 159 | navigate('/login?foo=bar') 160 | 161 | // Navigate to `/login?foo=bar` 162 | navigate({ path: '/login', search: { foo: 'bar' } }) 163 | 164 | // Navigate to `/` with replace 165 | navigate('/', true) 166 | 167 | // Navigate forward 168 | navigate(1) 169 | 170 | // Navigate backward 171 | navigate(-1) 172 | ``` 173 | 174 | ### Dynamic Syntax 175 | 176 | `svelte-router` supports defining dynamic segments in the string path and location descriptor object. This makes it convenient to navigate in deeply nested routes instead of relying on relative navigation. For example: 177 | 178 | ```svelte 179 | 184 | 185 | 196 | 197 | 198 | ... 199 | 200 | 201 | ... 202 | ``` 203 | 204 | Do note that: 205 | 206 | - In `hash` mode, the syntax will not work in the `search` portion of the location 207 | - In `path` mode, the syntax will not work in both the `search` and `hash` portion of the location 208 | 209 | ## Route Information 210 | 211 | Very often the code needs to receive information about the current route to perform certain tasks. 212 | 213 | You can use the global `$route` to do so by subscribing to it anywhere in your app: 214 | 215 | ```svelte 216 | 221 | ``` 222 | 223 | The `$route` contains properties such as `path`, `search`, `hash`, `params`, and `matched`. You can learn more about it in its [API reference](./api.md/#route). 224 | 225 | ## Link Information 226 | 227 | To find out if a link is partially or exactly matching the current path, `svelte-router` provides a `createLink()` function to easily retrieve this information. For example: 228 | 229 | ```svelte 230 | 236 | 237 | 249 | ``` 250 | 251 | Internally, the `` component also uses this [under-the-hood](https://github.com/bluwy/svelte-router/blob/master/src/components/Link.svelte), which means you can also use `createLink()` to create your own custom links! 252 | 253 | ## Redirects and Navigation Guards 254 | 255 | When navigating to certain invalid or protected routes, a redirect may be required. 256 | 257 | In `svelte-router`, the route `redirect` property can be used to acheive that: 258 | 259 | ```js 260 | [ 261 | // If path is `/secret`, redirect to `/login` 262 | { path: '/secret', redirect: '/login' } 263 | ] 264 | ``` 265 | 266 | Besides that, `redirect` also accepts a function or asynchronous function that may return the redirect path. This can be used to decide which path to redirect to depending on the condition. For example: 267 | 268 | ```js 269 | [ 270 | { 271 | path: '/secret', 272 | redirect: async () => { 273 | // Call external function to check if user is authenticated 274 | if (await isAuthenticated()) { 275 | // User is authenticated, stay on route 276 | return undefined 277 | } else { 278 | // User is not authenticated, redirect to `/` 279 | return '/' 280 | } 281 | } 282 | } 283 | ] 284 | ``` 285 | 286 | ## Lazy Loading Routes 287 | 288 | In large applications, it may be necessary to split up components to reduce the initial download size. This is done through dynamic importing components so that bundlers like [Rollup](https://rollupjs.org/) and [Webpack](https://webpack.js.org/) can perform code-splitting on them and only load when needed. 289 | 290 | Lazy loading works by using the [dynamic import syntax](https://github.com/tc39/proposal-dynamic-import): 291 | 292 | ```js 293 | [ 294 | { path: '/foo', component: import('./Foo.svelte') }, 295 | { path: '/bar', component: () => import('./Bar.svelte') } 296 | ] 297 | ``` 298 | 299 | ## Transitions 300 | 301 | Route components can use [svelte/transition](https://svelte.dev/tutorial/transition) to animate between routes. Wrap the route component with an element and apply the transition: 302 | 303 | ```svelte 304 | 307 | 308 |
309 | Content 310 |
311 | ``` 312 | 313 | When navigating to another route, if the current route component's descendants transition too, it may cause the UI to be in a half-transitioned state. There are 2 solutions: 314 | 315 | 1. Use [local transitions](https://svelte.dev/tutorial/local-transitions): Make sure the descendants' transitions are local, e.g. `
` 316 | 317 | 2. [Tick](https://svelte.dev/tutorial/tick) before navigate: This will wait for the descendants' transitions to end before navigating, e.g. `tick().then(() => navigate('/foo'))` 318 | 319 | > NOTE: Only apply the solutions above if the issue arises. 320 | 321 | ## Base Path 322 | 323 | In `path` mode, if the app is served under a specific path of a domain, then a `` tag needs to be declared in the ``. For example, if the app is hosted at `/app`: 324 | 325 | ```html 326 | 327 | 328 | 329 | 330 | ``` 331 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic 2 | 3 | A basic example 4 | 5 | ## Try it out 6 | 7 | Copy example project with [degit](https://github.com/Rich-Harris/degit): 8 | 9 | ```bash 10 | $ npx degit bluwy/svelte-router/examples/basic svelte-app 11 | ``` 12 | 13 | Install dependencies: 14 | 15 | ```bash 16 | $ cd svelte-app 17 | $ npm install 18 | ``` 19 | 20 | Run development server: 21 | 22 | ```bash 23 | $ npm run dev 24 | ``` 25 | 26 | Then, navigate to http://localhost:5000. You should see your app running. Edit a component file in `src`, save it, and the page will auto reload. 27 | 28 | To run in production: 29 | 30 | ```bash 31 | $ npm run build 32 | $ npm run start 33 | ``` 34 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "rollup -c", 7 | "dev": "rollup -cw", 8 | "start": "sirv public -s" 9 | }, 10 | "devDependencies": { 11 | "@bjornlu/svelte-router": "^0.4.1", 12 | "@rollup/plugin-commonjs": "^14.0.0", 13 | "@rollup/plugin-node-resolve": "^8.0.0", 14 | "rollup": "^2.3.4", 15 | "rollup-plugin-livereload": "^2.0.0", 16 | "rollup-plugin-svelte": "^6.0.0", 17 | "rollup-plugin-terser": "^7.0.0", 18 | "svelte": "^3.0.0" 19 | }, 20 | "dependencies": { 21 | "sirv-cli": "^1.0.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/basic/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Svelte Router - Basic 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/basic/rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import livereload from 'rollup-plugin-livereload' 5 | import { terser } from 'rollup-plugin-terser' 6 | 7 | const production = !process.env.ROLLUP_WATCH 8 | 9 | function serve() { 10 | let server 11 | 12 | function toExit() { 13 | if (server) server.kill(0) 14 | } 15 | 16 | return { 17 | writeBundle() { 18 | if (server) return 19 | server = require('child_process').spawn( 20 | 'npm', 21 | ['run', 'start', '--', '--dev'], 22 | { 23 | stdio: ['ignore', 'inherit', 'inherit'], 24 | shell: true 25 | } 26 | ) 27 | 28 | process.on('SIGTERM', toExit) 29 | process.on('exit', toExit) 30 | } 31 | } 32 | } 33 | 34 | export default { 35 | input: 'src/main.js', 36 | output: { 37 | sourcemap: true, 38 | format: 'iife', 39 | name: 'app', 40 | file: 'public/build/bundle.js' 41 | }, 42 | plugins: [ 43 | svelte({ 44 | // enable run-time checks when not in production 45 | dev: !production, 46 | // we'll extract any component CSS out into 47 | // a separate file - better for performance 48 | css: (css) => { 49 | css.write('bundle.css') 50 | } 51 | }), 52 | 53 | // If you have external dependencies installed from 54 | // npm, you'll most likely need these plugins. In 55 | // some cases you'll need additional configuration - 56 | // consult the documentation for details: 57 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 58 | resolve({ 59 | browser: true, 60 | dedupe: ['svelte'] 61 | }), 62 | commonjs(), 63 | 64 | // In dev mode, call `npm run start` once 65 | // the bundle has been generated 66 | !production && serve(), 67 | 68 | // Watch the `public` directory and refresh the 69 | // browser on changes when not in production 70 | !production && livereload('public'), 71 | 72 | // If we're building for production (npm run build 73 | // instead of npm run dev), minify 74 | production && terser() 75 | ], 76 | watch: { 77 | clearScreen: false 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /examples/basic/src/App.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /examples/basic/src/main.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte' 2 | import './router' 3 | 4 | const app = new App({ 5 | target: document.body 6 | }) 7 | 8 | export default app 9 | -------------------------------------------------------------------------------- /examples/basic/src/router.js: -------------------------------------------------------------------------------- 1 | import { initPathRouter } from '@bjornlu/svelte-router' 2 | import Home from './routes/Home.svelte' 3 | import User from './routes/User.svelte' 4 | 5 | // Use `initHashRouter` for hash mode 6 | initPathRouter([ 7 | { path: '/', component: Home }, 8 | { path: '/user/:id', component: User }, 9 | { path: '/secret', redirect: '/' } 10 | ]) 11 | -------------------------------------------------------------------------------- /examples/basic/src/routes/Home.svelte: -------------------------------------------------------------------------------- 1 |

Home

2 |

This is a home page

3 | -------------------------------------------------------------------------------- /examples/basic/src/routes/User.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

User

6 |

Hello, your user ID is "{$route.params.id}"

7 | -------------------------------------------------------------------------------- /examples/lazy-loading/README.md: -------------------------------------------------------------------------------- 1 | # Lazy Loading 2 | 3 | An example with lazy loading components. There are many ways to support dynamic imports, this examples uses ` 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/lazy-loading/rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import livereload from 'rollup-plugin-livereload' 5 | import { terser } from 'rollup-plugin-terser' 6 | 7 | const production = !process.env.ROLLUP_WATCH 8 | 9 | function serve() { 10 | let server 11 | 12 | function toExit() { 13 | if (server) server.kill(0) 14 | } 15 | 16 | return { 17 | writeBundle() { 18 | if (server) return 19 | server = require('child_process').spawn( 20 | 'npm', 21 | ['run', 'start', '--', '--dev'], 22 | { 23 | stdio: ['ignore', 'inherit', 'inherit'], 24 | shell: true 25 | } 26 | ) 27 | 28 | process.on('SIGTERM', toExit) 29 | process.on('exit', toExit) 30 | } 31 | } 32 | } 33 | 34 | export default { 35 | input: 'src/main.js', 36 | output: { 37 | sourcemap: true, 38 | format: 'es', 39 | name: 'app', 40 | dir: 'public/build' 41 | }, 42 | plugins: [ 43 | svelte({ 44 | // enable run-time checks when not in production 45 | dev: !production, 46 | // we'll extract any component CSS out into 47 | // a separate file - better for performance 48 | css: (css) => { 49 | css.write('bundle.css') 50 | } 51 | }), 52 | 53 | // If you have external dependencies installed from 54 | // npm, you'll most likely need these plugins. In 55 | // some cases you'll need additional configuration - 56 | // consult the documentation for details: 57 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 58 | resolve({ 59 | browser: true, 60 | dedupe: ['svelte'] 61 | }), 62 | commonjs(), 63 | 64 | // In dev mode, call `npm run start` once 65 | // the bundle has been generated 66 | !production && serve(), 67 | 68 | // Watch the `public` directory and refresh the 69 | // browser on changes when not in production 70 | !production && livereload('public'), 71 | 72 | // If we're building for production (npm run build 73 | // instead of npm run dev), minify 74 | production && terser() 75 | ], 76 | watch: { 77 | clearScreen: false 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /examples/lazy-loading/src/App.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /examples/lazy-loading/src/main.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte' 2 | import './router' 3 | 4 | const app = new App({ 5 | target: document.body 6 | }) 7 | 8 | export default app 9 | -------------------------------------------------------------------------------- /examples/lazy-loading/src/router.js: -------------------------------------------------------------------------------- 1 | import { initPathRouter } from '@bjornlu/svelte-router' 2 | import Home from './routes/Home.svelte' 3 | 4 | // Use `initHashRouter` for hash mode 5 | initPathRouter([ 6 | { path: '/', component: Home }, 7 | { path: '/user/:id', component: () => import('./routes/User.svelte') }, 8 | { path: '/secret', redirect: '/' } 9 | ]) 10 | -------------------------------------------------------------------------------- /examples/lazy-loading/src/routes/Home.svelte: -------------------------------------------------------------------------------- 1 |

Home

2 |

This is a home page

3 | -------------------------------------------------------------------------------- /examples/lazy-loading/src/routes/User.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

User

6 |

Hello, your user ID is "{$route.params.id}"

7 | -------------------------------------------------------------------------------- /examples/react-router/README.md: -------------------------------------------------------------------------------- 1 | # React router 2 | 3 | This example shows interoperability with react router. 4 | 5 | Note that only hash navigation is supported. See [issue #14](https://github.com/bluwy/svelte-router/issues/14) for details. 6 | 7 | ## Try it out 8 | 9 | Copy example project with [degit](https://github.com/Rich-Harris/degit): 10 | 11 | ```bash 12 | $ npx degit bluwy/svelte-router/examples/react-router svelte-app 13 | ``` 14 | 15 | Install dependencies: 16 | 17 | ```bash 18 | $ cd svelte-app 19 | $ npm install 20 | ``` 21 | 22 | Run development server: 23 | 24 | ```bash 25 | $ npm run dev 26 | ``` 27 | 28 | Then, navigate to http://localhost:5000. You should see your app running. Edit a component file in `src`, save it, and the page will auto reload. 29 | 30 | To run in production: 31 | 32 | ```bash 33 | $ npm run build 34 | $ npm run start 35 | ``` 36 | -------------------------------------------------------------------------------- /examples/react-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "rollup -c", 7 | "dev": "rollup -cw", 8 | "start": "sirv public -s" 9 | }, 10 | "devDependencies": { 11 | "@bjornlu/svelte-router": "^0.4.1", 12 | "@rollup/plugin-commonjs": "^14.0.0", 13 | "@rollup/plugin-node-resolve": "^8.0.0", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-router-dom": "^5.3.0", 17 | "rollup": "^2.3.4", 18 | "rollup-plugin-livereload": "^2.0.0", 19 | "rollup-plugin-node-builtins": "^2.1.2", 20 | "rollup-plugin-node-globals": "^1.4.0", 21 | "rollup-plugin-svelte": "^6.0.0", 22 | "rollup-plugin-terser": "^7.0.0", 23 | "svelte": "^3.0.0" 24 | }, 25 | "dependencies": { 26 | "sirv-cli": "^1.0.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/react-router/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Svelte Router - React Router 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/react-router/rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import livereload from 'rollup-plugin-livereload' 5 | import { terser } from 'rollup-plugin-terser' 6 | import globals from 'rollup-plugin-node-globals' 7 | import builtins from 'rollup-plugin-node-builtins' 8 | 9 | const production = !process.env.ROLLUP_WATCH 10 | 11 | function serve() { 12 | let server 13 | 14 | function toExit() { 15 | if (server) server.kill(0) 16 | } 17 | 18 | return { 19 | writeBundle() { 20 | if (server) return 21 | server = require('child_process').spawn( 22 | 'npm', 23 | ['run', 'start', '--', '--dev'], 24 | { 25 | stdio: ['ignore', 'inherit', 'inherit'], 26 | shell: true 27 | } 28 | ) 29 | 30 | process.on('SIGTERM', toExit) 31 | process.on('exit', toExit) 32 | } 33 | } 34 | } 35 | 36 | export default { 37 | input: 'src/main.js', 38 | output: { 39 | sourcemap: true, 40 | format: 'iife', 41 | name: 'app', 42 | file: 'public/build/bundle.js' 43 | }, 44 | plugins: [ 45 | svelte({ 46 | // enable run-time checks when not in production 47 | dev: !production, 48 | // we'll extract any component CSS out into 49 | // a separate file - better for performance 50 | css: (css) => { 51 | css.write('bundle.css') 52 | } 53 | }), 54 | 55 | // If you have external dependencies installed from 56 | // npm, you'll most likely need these plugins. In 57 | // some cases you'll need additional configuration - 58 | // consult the documentation for details: 59 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 60 | resolve({ 61 | browser: true, 62 | dedupe: ['svelte'] 63 | }), 64 | 65 | commonjs(), 66 | globals(), 67 | builtins(), 68 | 69 | // In dev mode, call `npm run start` once 70 | // the bundle has been generated 71 | !production && serve(), 72 | 73 | // Watch the `public` directory and refresh the 74 | // browser on changes when not in production 75 | !production && livereload('public'), 76 | 77 | // If we're building for production (npm run build 78 | // instead of npm run dev), minify 79 | production && terser() 80 | ], 81 | watch: { 82 | clearScreen: false 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /examples/react-router/src/App.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | This is the Svelte component with svelte-router. 7 | 12 | 13 |
14 | -------------------------------------------------------------------------------- /examples/react-router/src/main.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte' 2 | import './router' 3 | 4 | const app = new App({ 5 | target: document.body 6 | }) 7 | 8 | export default app 9 | -------------------------------------------------------------------------------- /examples/react-router/src/react/MyReactComponent.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom' 2 | import React from 'react' 3 | import { HashRouter as Router, Switch, Route, Link } from 'react-router-dom' 4 | 5 | export function MyReactComponent() { 6 | return React.createElement( 7 | Router, 8 | null, 9 | React.createElement( 10 | 'div', 11 | null, 12 | 'This is a react component with react-router', 13 | React.createElement('br', null), 14 | React.createElement(Link, { to: '/user/alain' }, 'Go to user'), 15 | ' ', 16 | React.createElement(Link, { to: '/home' }, 'Hide details'), 17 | ' ', 18 | React.createElement(Link, { to: '/home/details' }, 'Show details'), 19 | React.createElement('br', null), 20 | React.createElement( 21 | Switch, 22 | null, 23 | React.createElement( 24 | Route, 25 | { path: '/home/details' }, 26 | 'Here are the details' 27 | ) 28 | ) 29 | ) 30 | ) 31 | } 32 | 33 | class MyReactElement extends HTMLElement { 34 | connectedCallback() { 35 | const mountPoint = document.createElement('span') 36 | this.attachShadow({ mode: 'open' }).appendChild(mountPoint) 37 | ReactDOM.render( 38 | React.createElement(MyReactComponent, null, null), 39 | mountPoint 40 | ) 41 | } 42 | } 43 | customElements.define('my-react-element', MyReactElement) 44 | -------------------------------------------------------------------------------- /examples/react-router/src/router.js: -------------------------------------------------------------------------------- 1 | import { initHashRouter } from '@bjornlu/svelte-router' 2 | import Home from './routes/Home.svelte' 3 | import User from './routes/User.svelte' 4 | 5 | // Path mode is not compatible with react-router. Hash mode is the only option 6 | initHashRouter([ 7 | { path: '/', redirect: '/home' }, 8 | { path: '/home/*', component: Home }, 9 | { path: '/user/:id', component: User }, 10 | { path: '/secret', redirect: '/' } 11 | ]) 12 | -------------------------------------------------------------------------------- /examples/react-router/src/routes/Home.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Home

6 | 7 | -------------------------------------------------------------------------------- /examples/react-router/src/routes/User.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

User

6 |

Hello, your user ID is "{$route.params.id}"

7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testMatch: ['**/__tests__/**/*.spec.ts'], 4 | transform: { 5 | // Svelte components are tested in Cypress 6 | '^.+.svelte$': 'jest-transform-stub' 7 | }, 8 | globals: { 9 | 'ts-jest': { 10 | tsConfig: './__tests__/tsconfig.json' 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bjornlu/svelte-router", 3 | "description": "An easy-to-use SPA router for Svelte", 4 | "version": "0.5.0", 5 | "main": "dist/svelte-router.umd.js", 6 | "module": "dist/index.js", 7 | "svelte": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "sideEffects": false, 10 | "files": [ 11 | "dist" 12 | ], 13 | "author": { 14 | "name": "Bjorn Lu", 15 | "url": "https://bjornlu.com" 16 | }, 17 | "license": "MIT", 18 | "homepage": "https://github.com/bluwy/svelte-router", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/bluwy/svelte-router.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/bluwy/svelte-router/issues" 25 | }, 26 | "keywords": [ 27 | "svelte", 28 | "router" 29 | ], 30 | "scripts": { 31 | "clean": "rimraf dist", 32 | "build": "pnpm build:tsc && pnpm build:copy && pnpm build:bundle", 33 | "build:tsc": "tsc", 34 | "build:copy": "node ./scripts/copy-svelte.js", 35 | "build:bundle": "rollup -c", 36 | "test": "pnpm test:unit && pnpm test:e2e", 37 | "test:unit": "jest", 38 | "test:e2e": "server-test 'pnpm cy:setup' '10001|10002|10003' 'pnpm cy:run'", 39 | "cy:setup": "pnpm cy:setup:clean && pnpm cy:setup:build && pnpm cy:setup:serve", 40 | "cy:setup:clean": "rimraf cypress/test-app/public-*/build", 41 | "cy:setup:build": "rollup -c cypress/test-app/rollup.config.js", 42 | "cy:setup:serve": "node ./scripts/cy-serve.js", 43 | "cy:open": "cypress open", 44 | "cy:run": "cypress run --headless", 45 | "lint": "prettier --check '**/*.{html,js,ts,svelte}'", 46 | "format": "prettier --write '**/*.{html,js,ts,svelte}'", 47 | "prepublishOnly": "pnpm clean && pnpm build" 48 | }, 49 | "husky": { 50 | "hooks": { 51 | "pre-commit": "lint-staged" 52 | } 53 | }, 54 | "lint-staged": { 55 | "*.{html,js,ts,svelte}": "prettier --write" 56 | }, 57 | "peerDependencies": { 58 | "svelte": "^3.20.0" 59 | }, 60 | "dependencies": { 61 | "regexparam": "^1.3.0" 62 | }, 63 | "devDependencies": { 64 | "@rollup/plugin-node-resolve": "^9.0.0", 65 | "@rollup/plugin-replace": "^2.4.2", 66 | "@rollup/plugin-typescript": "^5.0.2", 67 | "@types/jest": "^26.0.24", 68 | "cypress": "^5.6.0", 69 | "husky": "^4.3.8", 70 | "jest": "^26.6.3", 71 | "jest-transform-stub": "^2.0.0", 72 | "lint-staged": "^10.5.4", 73 | "prettier": "^2.4.1", 74 | "prettier-plugin-svelte": "^2.4.0", 75 | "rimraf": "^3.0.2", 76 | "rollup": "^2.58.3", 77 | "rollup-plugin-svelte": "^6.1.1", 78 | "sirv": "^1.0.18", 79 | "start-server-and-test": "^1.14.0", 80 | "svelte": "3.49.0", 81 | "svelte-preprocess": "^4.9.8", 82 | "ts-jest": "^26.5.6", 83 | "ts-node": "^9.1.1", 84 | "tslib": "^2.3.1", 85 | "typescript": "^4.4.4" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - examples/* 3 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve' 2 | import svelte from 'rollup-plugin-svelte' 3 | import pkg from './package.json' 4 | 5 | const name = pkg.name 6 | .replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3') 7 | .replace(/^\w/, (m) => m.toUpperCase()) 8 | .replace(/-\w/g, (m) => m[1].toUpperCase()) 9 | 10 | export default { 11 | input: pkg.module, 12 | output: { file: pkg.main, format: 'umd', name }, 13 | plugins: [svelte(), resolve({ preventAssignment: true })] 14 | } 15 | -------------------------------------------------------------------------------- /scripts/copy-svelte.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises 2 | const path = require('path') 3 | const svelte = require('svelte/compiler') 4 | const svelteConfig = require('../svelte.config') 5 | 6 | main() 7 | 8 | async function main() { 9 | const cwd = process.cwd() 10 | 11 | await fs.mkdir(path.resolve(cwd, 'dist/components'), { recursive: true }) 12 | 13 | // Copy RouterView component 14 | await preprocessSvelte( 15 | path.resolve(cwd, 'src/components/RouterView.svelte'), 16 | path.resolve(cwd, 'dist/components/RouterView.svelte') 17 | ) 18 | 19 | // Copy Link component 20 | await preprocessSvelte( 21 | path.resolve(cwd, 'src/components/Link.svelte'), 22 | path.resolve(cwd, 'dist/components/Link.svelte') 23 | ) 24 | } 25 | 26 | async function preprocessSvelte(src, dest) { 27 | const srcCode = await fs.readFile(src, { encoding: 'utf-8' }) 28 | 29 | let { code } = await svelte.preprocess(srcCode, svelteConfig.preprocess, { 30 | filename: src 31 | }) 32 | 33 | // Remove lang to prevent preprocess on user side 34 | code = code.replace('lang="ts"', '') 35 | 36 | await fs.writeFile(dest, code, { encoding: 'utf-8' }) 37 | } 38 | -------------------------------------------------------------------------------- /scripts/cy-serve.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const path = require('path') 3 | const sirv = require('sirv') 4 | 5 | main() 6 | 7 | async function main() { 8 | await serve() 9 | } 10 | 11 | async function serve() { 12 | const cwd = process.cwd() 13 | 14 | const servers = [ 15 | { dir: 'public-hash', port: 10001, single: false }, 16 | { dir: 'public-path', port: 10002, single: true }, 17 | { dir: 'public-path-base', port: 10003, single: true } 18 | ] 19 | 20 | servers.forEach((server) => { 21 | http 22 | .createServer( 23 | sirv(path.resolve(cwd, `cypress/test-app/${server.dir}`), { 24 | dev: true, 25 | single: true 26 | }) 27 | ) 28 | .listen(server.port, () => { 29 | console.log( 30 | green(server.dir) + ' -> ' + green(`http://localhost:${server.port}`) 31 | ) 32 | }) 33 | }) 34 | } 35 | 36 | function green(text) { 37 | return '\u001b[1m\u001b[32m' + text + '\u001b[39m\u001b[22m' 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Link.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/components/RouterView.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 35 | 36 | {#if canRender} 37 | {#if component != null} 38 | 39 | {#if hasChildren} 40 | 41 | {/if} 42 | 43 | {:else if hasChildren} 44 | 45 | {/if} 46 | {/if} 47 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | import { writable, Readable } from 'svelte/store' 2 | import { Router, Route } from './router/base' 3 | import { HashRouter } from './router/hash-router' 4 | import { PathRouter } from './router/path-router' 5 | import { RouteRecord } from './types' 6 | 7 | let globalRouter: Router | undefined 8 | 9 | const writableRoute = writable({ 10 | path: '', 11 | params: {}, 12 | matched: [], 13 | search: new URLSearchParams(), 14 | hash: '' 15 | }) 16 | 17 | export const route: Readable = { subscribe: writableRoute.subscribe } 18 | 19 | export const navigate: Router['navigate'] = function () { 20 | if (globalRouter != null) { 21 | // @ts-expect-error 22 | return globalRouter.navigate(...arguments) 23 | } else { 24 | throw new Error('Router must be initialized before calling navigate') 25 | } 26 | } 27 | 28 | export const createLink: Router['createLink'] = function () { 29 | if (globalRouter != null) { 30 | // @ts-expect-error 31 | return globalRouter.createLink(...arguments) 32 | } else { 33 | throw new Error('Router must be initialized before calling createLink') 34 | } 35 | } 36 | 37 | export function initHashRouter(routes: RouteRecord[]) { 38 | initRouter(new HashRouter(routes)) 39 | } 40 | 41 | export function initPathRouter(routes: RouteRecord[]) { 42 | initRouter(new PathRouter(routes)) 43 | } 44 | 45 | function initRouter(router: Router) { 46 | if (globalRouter == null) { 47 | globalRouter = router 48 | globalRouter.currentRoute.subscribe((v) => writableRoute.set(v)) 49 | } else { 50 | throw new Error('Router already initialized. Cannot re-initialize router.') 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './location-change-shim' 2 | export { default as RouterView } from './components/RouterView.svelte' 3 | export { default as Link } from './components/Link.svelte' 4 | export { LinkState, Route } from './router/base' 5 | export { LocationInput, RouteRecord } from './types' 6 | export { 7 | route, 8 | createLink, 9 | navigate, 10 | initHashRouter, 11 | initPathRouter 12 | } from './global' 13 | -------------------------------------------------------------------------------- /src/location-change-shim.ts: -------------------------------------------------------------------------------- 1 | // Listen to location changes that are triggered by: 2 | // - pushState 3 | // - replaceState 4 | // - onpopstate 5 | // - onhashchange 6 | 7 | export const LOCATION_CHANGE = 'svelterouterlocationchange' 8 | 9 | const originalPushState = history.pushState 10 | const originalReplaceState = history.replaceState 11 | 12 | let prevLocation = '' 13 | 14 | function dispatch() { 15 | if (window.location.href !== prevLocation) { 16 | window.dispatchEvent(new CustomEvent(LOCATION_CHANGE)) 17 | prevLocation = window.location.href 18 | } 19 | } 20 | 21 | history.pushState = function (...args) { 22 | originalPushState.apply(this, args) 23 | dispatch() 24 | } 25 | 26 | history.replaceState = function (...args) { 27 | originalReplaceState.apply(this, args) 28 | dispatch() 29 | } 30 | 31 | window.addEventListener('popstate', dispatch) 32 | window.addEventListener('hashchange', dispatch) 33 | -------------------------------------------------------------------------------- /src/matcher.ts: -------------------------------------------------------------------------------- 1 | import regexparam from 'regexparam' 2 | import { ensureTrailingSlash, formatPath, joinPaths } from './util' 3 | import { RouteRecord } from './types' 4 | 5 | export interface MatchedRoute { 6 | /** The current path, e.g. "/foo/bar" */ 7 | path: string 8 | /** 9 | * Key-value pairs of route dynamic segments and wildcards. Access wildcard 10 | * values with the 'wild' key. 11 | */ 12 | params: Record 13 | /** Array of all matched route records for this path */ 14 | matched: RouteRecord[] 15 | } 16 | 17 | interface RouteMatchData { 18 | /** The current formatted path */ 19 | path: string 20 | /** The pattern generated by regexparam */ 21 | pattern: RegExp 22 | /** The keys the pattern will match to */ 23 | keys: string[] 24 | /** Array of all matched routes for this path */ 25 | matched: RouteRecord[] 26 | } 27 | 28 | /** Metadata used on route parent when traversing routes in buildDatas */ 29 | type RouteMatchParent = Pick 30 | 31 | export class RouteMatcher { 32 | private readonly matchDatas: RouteMatchData[] = [] 33 | 34 | constructor(routes: RouteRecord[] = []) { 35 | this.buildDatas(routes) 36 | } 37 | 38 | /** Finds a route based on target path and the computed matchers. */ 39 | matchRoute(path: string): MatchedRoute | undefined { 40 | // Add trailing slash to route path so it properly matches nested routes too. 41 | // e.g. /foo should match /foo/* 42 | const matchPath = ensureTrailingSlash(formatPath(path)) 43 | 44 | for (const matchData of this.matchDatas) { 45 | const params: Record = {} 46 | const matchResult = matchPath.match(matchData.pattern) 47 | 48 | if (matchResult) { 49 | for (let i = 0; i < matchData.keys.length; i++) { 50 | params[matchData.keys[i]] = matchResult[i + 1] 51 | } 52 | 53 | if ('wild' in params) { 54 | params.wild = formatPath(params.wild) 55 | } 56 | 57 | return { 58 | path, 59 | params, 60 | matched: matchData.matched 61 | } 62 | } 63 | } 64 | } 65 | 66 | /** 67 | * Convert the routes as datas that contains information for path matching. 68 | * This will recursively traverse child routes and flatten into `matchDatas`. 69 | */ 70 | private buildDatas( 71 | routes: RouteRecord[], 72 | parentData: RouteMatchParent = { path: '', matched: [] } 73 | ) { 74 | routes.forEach((route) => { 75 | // Cumulative metadata when traversing parents 76 | const parent: RouteMatchParent = { 77 | path: joinPaths(parentData.path, route.path), 78 | matched: parentData.matched.concat(route) 79 | } 80 | 81 | if (route.children?.length) { 82 | this.buildDatas(route.children, parent) 83 | } else { 84 | this.matchDatas.push({ 85 | ...parent, 86 | ...regexparam(parent.path) 87 | }) 88 | } 89 | }) 90 | 91 | // Make sure "/" doesn't fall through if the routes' paths do. 92 | // Example route config: 93 | // "/foo" 94 | // - "/bar" 95 | // match('/foo') should render "/foo" component but with empty child 96 | if (!routes.some((route) => ['', '/', '*', '/*'].includes(route.path))) { 97 | this.matchDatas.push({ 98 | ...parentData, 99 | ...regexparam(parentData.path) 100 | }) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/router/base.ts: -------------------------------------------------------------------------------- 1 | import { Readable, readable, derived } from 'svelte/store' 2 | import { MatchedRoute, RouteMatcher } from '../matcher' 3 | import { LocationInput, RouteRecord } from '../types' 4 | import { LOCATION_CHANGE } from '../location-change-shim' 5 | import { parseLocationInput, formatPath } from '../util' 6 | 7 | export interface Route extends MatchedRoute { 8 | search: URLSearchParams 9 | hash: string 10 | } 11 | 12 | export interface LinkState { 13 | href: string 14 | isActive: boolean 15 | isExactActive: boolean 16 | } 17 | 18 | export abstract class Router { 19 | readonly currentRoute: Readable 20 | private readonly routeMatcher: RouteMatcher 21 | 22 | constructor(routes: RouteRecord[]) { 23 | this.routeMatcher = new RouteMatcher(routes) 24 | 25 | this.currentRoute = readable(this.getCurrentRoute(), (set) => { 26 | const handleChange = () => set(this.getCurrentRoute()) 27 | 28 | window.addEventListener(LOCATION_CHANGE, handleChange) 29 | 30 | return () => window.removeEventListener(LOCATION_CHANGE, handleChange) 31 | }) 32 | 33 | // Format URL on page load 34 | this.navigate(this.getCurrentLocationInput(), true) 35 | } 36 | 37 | /** 38 | * Navigates to the url but do not create a new entry in the history 39 | */ 40 | protected abstract replace(url: string): void 41 | /** 42 | * Navigates to the url, leaving behind a trace in the history 43 | */ 44 | protected abstract push(url: string): void 45 | protected abstract getCurrentPath(): string 46 | protected abstract getCurrentLocationInput(): LocationInput 47 | protected abstract getPath(to: LocationInput): string | undefined 48 | protected abstract createUrl(to: LocationInput): string 49 | protected abstract createLinkHref(to: LocationInput): string 50 | protected abstract replaceParams( 51 | to: LocationInput, 52 | params?: Record 53 | ): LocationInput 54 | 55 | /** 56 | * Navigate using an offset in the current history. Works the same way as 57 | * [`history.go`](https://developer.mozilla.org/en-US/docs/Web/API/History/go). 58 | */ 59 | navigate(to: number): void 60 | /** 61 | * Navigate to a route using a path string or a location descriptor object 62 | */ 63 | navigate(to: string | LocationInput, replace?: boolean): void 64 | navigate(to: number | string | LocationInput, replace = false) { 65 | if (typeof to === 'number') { 66 | history.go(to) 67 | return 68 | } 69 | 70 | if (typeof to === 'string') { 71 | to = parseLocationInput(to) 72 | } 73 | 74 | const url = this.createUrl(this.replaceParams(to)) 75 | 76 | if (replace) { 77 | this.replace(url) 78 | } else { 79 | this.push(url) 80 | } 81 | } 82 | 83 | /** Returns a readable store that contains the given link's information */ 84 | createLink(to: string | LocationInput): Readable { 85 | const input = typeof to === 'string' ? parseLocationInput(to) : to 86 | 87 | return derived(this.currentRoute, ($currentRoute) => { 88 | const replacedInput = this.replaceParams(input, $currentRoute.params) 89 | const href = this.createLinkHref(replacedInput) 90 | const path = this.getPath(replacedInput) 91 | 92 | if (path == null) { 93 | return { 94 | href, 95 | isActive: false, 96 | isExactActive: false 97 | } 98 | } 99 | 100 | const formattedPath = formatPath(path) 101 | const routePath = $currentRoute.path 102 | 103 | return { 104 | href, 105 | isActive: routePath.startsWith(formattedPath), 106 | isExactActive: routePath === formattedPath 107 | } 108 | }) 109 | } 110 | 111 | private getCurrentRoute(): Route { 112 | const currentPath = this.getCurrentPath() 113 | const matchedRoute = this.routeMatcher.matchRoute(currentPath) 114 | 115 | return { 116 | path: currentPath, 117 | search: new URLSearchParams(window.location.search), 118 | hash: window.location.hash, 119 | params: matchedRoute?.params ?? {}, 120 | matched: matchedRoute?.matched ?? [] 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/router/hash-router.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'svelte/store' 2 | import { Router } from './base' 3 | import { LocationInput } from '../types' 4 | import { formatPath, replacePathParams } from '../util' 5 | 6 | export class HashRouter extends Router { 7 | getCurrentPath() { 8 | return formatPath(window.location.hash.slice(1)) 9 | } 10 | 11 | getCurrentLocationInput(): LocationInput { 12 | return { 13 | hash: '#' + this.getCurrentPath(), 14 | search: window.location.search 15 | } 16 | } 17 | 18 | getPath(to: LocationInput) { 19 | return to.path ?? to.hash?.slice(1) 20 | } 21 | 22 | createUrl(to: LocationInput) { 23 | const url = new URL(window.location.href) 24 | 25 | if (to.path) { 26 | url.hash = '#' + to.path 27 | } else { 28 | url.hash = to.hash ?? '' 29 | } 30 | 31 | url.search = to.search 32 | ? '?' + new URLSearchParams(to.search).toString() 33 | : '' 34 | 35 | return url.toString() 36 | } 37 | 38 | createLinkHref(to: LocationInput) { 39 | const search = to.search ?? '' 40 | const hash = to.path != null ? '#' + to.path : to.hash ?? '' 41 | 42 | return search + hash 43 | } 44 | 45 | replaceParams(to: LocationInput, params?: Record) { 46 | const newTo = { ...to } 47 | const routeParams = params ?? get(this.currentRoute).params 48 | 49 | if (newTo.path) { 50 | newTo.path = replacePathParams(newTo.path, routeParams) 51 | } 52 | 53 | if (newTo.hash) { 54 | newTo.hash = replacePathParams(newTo.hash, routeParams) 55 | } 56 | 57 | return newTo 58 | } 59 | 60 | replace(url: string) { 61 | location.replace(url) 62 | } 63 | 64 | push(url: string) { 65 | location.assign(url) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/router/path-router.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'svelte/store' 2 | import { Router } from './base' 3 | import { LocationInput } from '../types' 4 | import { formatPath, getBasePath, joinPaths, replacePathParams } from '../util' 5 | 6 | const basePath = /*#__PURE__*/ getBasePath() 7 | 8 | export class PathRouter extends Router { 9 | getCurrentPath() { 10 | return formatPath(window.location.pathname.replace(basePath, '')) 11 | } 12 | 13 | getCurrentLocationInput(): LocationInput { 14 | return { 15 | path: this.getCurrentPath(), 16 | hash: window.location.hash, 17 | search: window.location.search 18 | } 19 | } 20 | 21 | getPath(to: LocationInput) { 22 | return to.path 23 | } 24 | 25 | createUrl(to: LocationInput) { 26 | const url = new URL(window.location.href) 27 | 28 | if (to.path) { 29 | url.pathname = joinPaths(basePath, to.path) 30 | } 31 | 32 | url.search = to.search 33 | ? '?' + new URLSearchParams(to.search).toString() 34 | : '' 35 | 36 | url.hash = to.hash ?? '' 37 | 38 | return url.toString() 39 | } 40 | 41 | createLinkHref(to: LocationInput) { 42 | const path = to.path ?? '' 43 | const search = to.search ?? '' 44 | const hash = to.hash ?? '' 45 | 46 | return joinPaths(basePath, path) + search + hash 47 | } 48 | 49 | replaceParams(to: LocationInput, params?: Record) { 50 | const newTo = { ...to } 51 | const routeParams = params ?? get(this.currentRoute).params 52 | 53 | if (newTo.path) { 54 | newTo.path = replacePathParams(newTo.path, routeParams) 55 | } 56 | 57 | return newTo 58 | } 59 | 60 | replace(url: string) { 61 | history.replaceState(url, '', url) 62 | } 63 | 64 | push(url: string) { 65 | history.pushState(url, '', url) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Thunk = T | (() => T) 2 | 3 | export type Promisable = T | Promise 4 | 5 | export interface LocationInput { 6 | path?: string 7 | search?: ConstructorParameters[0] 8 | hash?: string 9 | } 10 | 11 | export interface RouteRecord { 12 | /** The route path, e.g. "/foo" */ 13 | path: string 14 | /** Svelte component */ 15 | component?: Thunk> 16 | /** Redirect to another path if route match */ 17 | redirect?: Thunk> 18 | /** 19 | * Array of children routes. If defined, this route component requires a 20 | * `` component to render the children routes. 21 | */ 22 | children?: RouteRecord[] 23 | } 24 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { Thunk, Promisable, LocationInput } from './types' 2 | 3 | export function getBasePath() { 4 | return document.getElementsByTagName('base').length > 0 5 | ? document.baseURI.replace(window.location.origin, '') 6 | : '/' 7 | } 8 | 9 | export function parseLocationInput(to: string): LocationInput { 10 | const url = new URL(to, 'https://example.com') 11 | 12 | return { 13 | path: to.startsWith('/') ? url.pathname : undefined, 14 | search: url.search, 15 | hash: url.hash 16 | } 17 | } 18 | 19 | /** Replace dynamic segments in path, e.g. `/user/:id` => `/user/foo` */ 20 | export function replacePathParams( 21 | path: string, 22 | params: Record 23 | ) { 24 | return path.replace(/:([^/]+)/g, (o, v) => params[v] ?? o) 25 | } 26 | 27 | /** Makes sure path has leading "/" and no trailing "/" */ 28 | export function formatPath(path: string) { 29 | if (path.endsWith('/')) { 30 | path = path.slice(0, -1) 31 | } 32 | 33 | if (!path.startsWith('/')) { 34 | path = '/' + path 35 | } 36 | 37 | return path 38 | } 39 | 40 | /** Joins multiple path and also formats it */ 41 | export function joinPaths(...paths: string[]) { 42 | return paths 43 | .map(formatPath) 44 | .filter((v) => v !== '/') 45 | .join('') 46 | } 47 | 48 | export function ensureTrailingSlash(path: string) { 49 | if (!path.endsWith('/')) { 50 | path += '/' 51 | } 52 | 53 | return path 54 | } 55 | 56 | export function handleThunk(thunk: Thunk): T { 57 | return typeof thunk === 'function' ? (thunk as any)() : thunk 58 | } 59 | 60 | export function handleComponentThunk(thunk: Thunk): T { 61 | // Svelte components are classes/functions so thunk won't actually work. 62 | // The workaround is by assuming this syntax `() => import` to be used. 63 | // Since lambda has no prototype, so we check it before calling thunk. 64 | return typeof thunk === 'function' && !('prototype' in thunk) 65 | ? (thunk as any)() 66 | : thunk 67 | } 68 | 69 | export function handlePromisable( 70 | promisable: Promisable, 71 | callback: (value: T) => void 72 | ) { 73 | if (promisable instanceof Promise) { 74 | promisable.then(callback) 75 | } else { 76 | callback(promisable) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | const sveltePreprocess = require('svelte-preprocess') 2 | 3 | module.exports = { 4 | preprocess: sveltePreprocess() 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "moduleResolution": "node", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "types": ["svelte"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "declaration": true 7 | }, 8 | "include": ["./src/**/*"], 9 | "exclude": ["node_modules"] 10 | } 11 | --------------------------------------------------------------------------------