├── .codeclimate.yml ├── .eslintrc.yaml ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── demos ├── components │ ├── app.riot │ └── user.riot ├── riot-history.html ├── standalone-hash.html └── standalone-history.html ├── index.d.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── components │ ├── route-hoc.js │ ├── route-hoc.riot │ ├── router-hoc.js │ └── router-hoc.riot ├── constants.js ├── dom.js ├── get-current-route.js ├── index.js ├── set-base.js └── util.js └── test ├── components.spec.js ├── components ├── computed-routes.riot ├── history-router-app.riot ├── nested-updates.riot ├── recursive-updates-bug-router.riot ├── recursive-updates-bug148.riot ├── same-route-matches.riot ├── spred-props-router.riot ├── static-base-path.riot └── user.riot ├── misc.spec.js ├── setup.js ├── standalone-hash-dom.spec.js ├── standalone-history-dom.spec.js └── util.js /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | ratings: 2 | paths: 3 | - 'src/**.js' 4 | 5 | exclude_paths: 6 | - 'route.js' 7 | - 'route.esm.js' 8 | - 'route.standalone.js' 9 | - 'test/**' 10 | - 'demos/**' 11 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: eslint-config-riot 2 | 3 | rules: 4 | fp/no-mutating-methods: 0 5 | fp/no-rest-parameters: 0 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | pull_request: 7 | branches: [dev] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x] 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Local Unit Test ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm i 24 | - run: npm test 25 | - name: Generate Coverage 26 | if: ${{ success() && github.event_name != 'pull_request' && matrix.node-version == '20.x' }} 27 | run: npm run cov 28 | - name: Coveralls 29 | if: ${{ success() && github.event_name != 'pull_request' && matrix.node-version == '20.x' }} 30 | uses: coverallsapp/github-action@master 31 | with: 32 | path-to-lcov: ./coverage/lcov.info 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # editor files 21 | .idea 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # mac useless file 40 | .DS_Store 41 | 42 | # Generated files 43 | index.js 44 | index.umd.js 45 | index.standalone.js 46 | index.standalone.umd.js 47 | demos/components/*.js 48 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('@riotjs/prettier-config') 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Gianluca Guarini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Riot Router 2 | 3 | [![Route logo](https://raw.githubusercontent.com/riot/branding/main/route/route-horizontal.svg)](https://github.com/riot/route/) 4 | 5 | [![Build Status][ci-image]][ci-url] [![Code Quality][codeclimate-image]][codeclimate-url] [![NPM version][npm-version-image]][npm-url] [![NPM downloads][npm-downloads-image]][npm-url] [![MIT License][license-image]][license-url] [![Coverage Status][coverage-image]][coverage-url] 6 | 7 | > Simple isomorphic router 8 | 9 | The Riot.js Router is the minimal router implementation with such technologies: 10 | 11 | - compatible with the DOM pushState and history API 12 | - isomorphic functional API 13 | - [erre.js streams](https://github.com/GianlucaGuarini/erre) and javascript async generators 14 | - [rawth.js](https://github.com/GianlucaGuarini/rawth) urls parsing 15 | 16 | It doesn't need Riot.js to work and can be used as standalone module. 17 | 18 | **For Riot.js 3 and the older route version please check the [v3 branch](https://github.com/riot/route/tree/v3)** 19 | 20 | ## Table of Contents 21 | 22 | - [Install](#install) 23 | - [Documentation](#documentation) 24 | - [Demos](https://github.com/riot/examples) 25 | 26 | ## Install 27 | 28 | We have 2 editions: 29 | 30 | | edition | file | 31 | | :------------------------ | :------------------------ | 32 | | **ESM Module** | `index.js` | 33 | | **UMD Version** | `index.umd.js` | 34 | | **Standalone ESM Module** | `index.standalone.js` | 35 | | **Standalone UMD Module** | `index.standalone.umd.js` | 36 | 37 | ### Script injection 38 | 39 | ```html 40 | 41 | ``` 42 | 43 | _Note_: change the part `x.x.x` to the version numbers what you want to use: ex. `4.5.0` or `4.7.0`. 44 | 45 | ### ESM module 46 | 47 | ```js 48 | import { route } from 'https://unpkg.com/@riotjs/route/index.js' 49 | ``` 50 | 51 | ### npm 52 | 53 | ```bash 54 | $ npm i -S @riotjs/route 55 | ``` 56 | 57 | ### Download by yourself 58 | 59 | - [Standalone](https://unpkg.com/@riotjs/route/route.js) 60 | - [ESM](https://unpkg.com/@riotjs/route/route.esm.js) 61 | 62 | ## Documentation 63 | 64 | ### With Riot.js 65 | 66 | You can import the `` and `` components in your application and use them as it follows: 67 | 68 | ```html 69 | 70 | 71 | 72 | 77 | 78 | 79 | Home page 80 | About 81 | Hello dear { route.params.person } 82 | 83 | 84 | 91 | 92 | ``` 93 | 94 | You can also use the `riot.register` method to register them globally 95 | 96 | ```js 97 | import { Route, Router } from '@riotjs/route' 98 | import { register } from 'riot' 99 | 100 | // now the Router and Route components are globally available 101 | register('router', Router) 102 | register('route', Route) 103 | ``` 104 | 105 | #### Router 106 | 107 | The `` component should wrap your application markup and will detect automatically all the clicks on links that should trigger a route event. 108 | 109 | ```html 110 | 111 | 112 | Link 113 | 114 | 115 | Link 116 | ``` 117 | 118 | You can also specify the base of your application via component attributes: 119 | 120 | ```html 121 | 122 | 123 | Link 124 | 125 | ``` 126 | 127 | The router component has also an `onStarted` callback that will be called asynchronously after the first route event will be called 128 | 129 | ```html 130 | 131 | ``` 132 | 133 | #### Route 134 | 135 | The `` component provides the `route` property to its children (it's simply a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object) allowing you to detect the url params and queries. 136 | 137 | ```html 138 | {JSON.stringify(route.params)} 139 | 140 | 141 | 142 | 143 | {route.searchParams.get('q')} 144 | 145 | ``` 146 | 147 | Each `` component has its own lifecycle attributes in order to let you know when it gets mounted or unmounted. 148 | 149 | ```riot 150 | 151 | 152 | 160 | 161 | 162 | ``` 163 | 164 | ### Standalone 165 | 166 | This module was not only designed to be used with Riot.js but also as standalone module. 167 | Without importing the Riot.js components in your application you can use the core methods exported to build and customize your own router compatible with any kind of frontend setup. 168 | 169 | Depending on your project setup you might import it as follows: 170 | 171 | ```js 172 | // in a Riot.js application 173 | import { route } from '@riotjs/route' 174 | 175 | // in a standalone context 176 | import { route } from '@riotjs/route/standalone' 177 | ``` 178 | 179 | #### Fundamentals 180 | 181 | This module works on node and on any modern browser, it exports the `router` and `router` property exposed by [rawth](https://github.com/GianlucaGuarini/rawth) 182 | 183 | ```js 184 | import { route, router, setBase } from '@riotjs/route' 185 | 186 | // required to set base first 187 | setBase('/') 188 | 189 | // create a route stream 190 | const aboutStream = route('/about') 191 | 192 | aboutStream.on.value((url) => { 193 | console.log(url) // URL object 194 | }) 195 | 196 | aboutStream.on.value(() => { 197 | console.log('just log that the about route was triggered') 198 | }) 199 | 200 | // triggered on each route event 201 | router.on.value((path) => { 202 | // path is always a string in this function 203 | console.log(path) 204 | }) 205 | 206 | // trigger a route change manually 207 | router.push('/about') 208 | 209 | // end the stream 210 | aboutStream.end() 211 | ``` 212 | 213 | #### Base path 214 | 215 | Before using the router in your browser you will need to set your application base path. 216 | This setting can be configured simply via `setBase` method: 217 | 218 | ```js 219 | import { setBase } from '@riotjs/route' 220 | 221 | // in case you want to use the HTML5 history navigation 222 | setBase(`/`) 223 | 224 | // in case you use the hash navigation 225 | setBase(`#`) 226 | ``` 227 | 228 | Setting the base path of your application route is mandatory and is the first you probably are going to do before creating your route listeners. 229 | 230 | #### DOM binding 231 | 232 | The example above is not really practical in case you are working in a browser environment. In that case you might want to bind your router to the DOM listening all the click events that might trigger a route change event. 233 | Window history `popstate` events should be also connected to the router. 234 | With the `initDomListeners` method you can automatically achieve all the features above: 235 | 236 | ```js 237 | import { initDomListeners } from '@riotjs/route' 238 | 239 | const unsubscribe = initDomListeners() 240 | // the router is connected to the page DOM 241 | 242 | // ...tear down and disconnect the router from the DOM 243 | unsubscribe() 244 | ``` 245 | 246 | The `initDomListeners` will intercept any link click on your application. However it can also receive a HTMLElement or a list of HTMLElements as argument to scope the click listener only to a specific DOM region of your application 247 | 248 | ```js 249 | import { initDomListeners } from '@riotjs/route' 250 | 251 | initDomListeners(document.querySelector('.main-navigation')) 252 | ``` 253 | 254 | [ci-image]: https://img.shields.io/github/actions/workflow/status/riot/route/test.yml?style=flat-square 255 | [ci-url]: https://github.com/riot/route/actions 256 | [license-image]: http://img.shields.io/badge/license-MIT-000000.svg?style=flat-square 257 | [license-url]: LICENSE.txt 258 | [npm-version-image]: http://img.shields.io/npm/v/@riotjs/route.svg?style=flat-square 259 | [npm-downloads-image]: http://img.shields.io/npm/dm/@riotjs/route.svg?style=flat-square 260 | [npm-url]: https://npmjs.org/package/@riotjs/route 261 | [coverage-image]: https://img.shields.io/coveralls/riot/route/main.svg?style=flat-square 262 | [coverage-url]: https://coveralls.io/github/riot/route/?branch=main 263 | [codeclimate-image]: https://api.codeclimate.com/v1/badges/1487b171ba4409b5c302/maintainability 264 | [codeclimate-url]: https://codeclimate.com/github/riot/route 265 | -------------------------------------------------------------------------------- /demos/components/app.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | Every route :) 13 | 14 | hello 15 | user 16 | 17 | 18 | 19 | goodbye 20 | 21 | 22 | 29 | 30 | -------------------------------------------------------------------------------- /demos/components/user.riot: -------------------------------------------------------------------------------- 1 | 2 | User {JSON.stringify(props)} 3 | 4 |

i am an anchor

5 | 6 | 11 |
12 | -------------------------------------------------------------------------------- /demos/riot-history.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Riot.js history 7 | 8 | 9 | 10 | 11 |
12 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /demos/standalone-hash.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Standalone Hash Demo 7 | 8 | 9 | 10 | 16 |
17 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /demos/standalone-history.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Standalone History Demo 7 | 8 | 9 | 10 | 16 |
17 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { RiotComponentWrapper, RiotComponent } from 'riot' 2 | import { URLWithParams } from 'rawth' 3 | 4 | export * from 'rawth' 5 | 6 | export declare const Route: RiotComponentWrapper< 7 | RiotComponent<{ 8 | path: string 9 | 'on-before-mount'?: (path: URLWithParams) => void 10 | 'on-mounted'?: (path: URLWithParams) => void 11 | 'on-before-unmount'?: (path: URLWithParams) => void 12 | 'on-unmounted'?: (path: URLWithParams) => void 13 | }> 14 | > 15 | 16 | export declare const Router: RiotComponentWrapper< 17 | RiotComponent<{ 18 | base?: string 19 | 'initial-route'?: string 20 | 'on-started'?: (route: string) => void 21 | }> 22 | > 23 | 24 | export declare function getCurrentRoute(): string 25 | export declare function initDomListeners(): void 26 | export declare function setBase(base: string): void 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@riotjs/route", 3 | "version": "9.2.2", 4 | "description": "Riot.js isomorphic router", 5 | "type": "module", 6 | "main": "index.umd.js", 7 | "jsnext:main": "index.js", 8 | "module": "index.js", 9 | "exports": { 10 | ".": { 11 | "types": "./index.d.ts", 12 | "import": "./index.js", 13 | "require": "./index.umd.js", 14 | "browser": "./index.umd.js" 15 | }, 16 | "./standalone": { 17 | "types": "./index.d.ts", 18 | "import": "./index.standalone.js", 19 | "require": "./index.standalone.umd.js", 20 | "browser": "./index.standalone.umd.js" 21 | } 22 | }, 23 | "scripts": { 24 | "prepublishOnly": "npm run build && npm test", 25 | "lint": "eslint src test rollup.config.js && prettier -c .", 26 | "build": "rollup -c && npm run build-demo", 27 | "build-demo": "riot demos/components -o demos/components", 28 | "demo": "npm run build && serve", 29 | "cov": "c8 report --reporter=lcov", 30 | "cov-html": "c8 report --reporter=html", 31 | "test": "npm run lint && c8 mocha -r test/setup.js test/*.spec.js" 32 | }, 33 | "files": [ 34 | "index.d.ts", 35 | "index.js", 36 | "index.umd.js", 37 | "index.standalone.js", 38 | "index.standalone.umd.js" 39 | ], 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/riot/route.git" 43 | }, 44 | "keywords": [ 45 | "riot", 46 | "Riot.js", 47 | "router", 48 | "riot-route", 49 | "route" 50 | ], 51 | "author": "Gianluca Guarini (https://gianlucaguarini.com)", 52 | "license": "MIT", 53 | "bugs": { 54 | "url": "https://github.com/riot/route/issues" 55 | }, 56 | "homepage": "https://github.com/riot/route#readme", 57 | "devDependencies": { 58 | "@riotjs/cli": "^9.0.5", 59 | "@riotjs/compiler": "^9.4.2", 60 | "@riotjs/prettier-config": "^1.1.0", 61 | "@riotjs/register": "^9.1.0", 62 | "@rollup/plugin-commonjs": "^28.0.2", 63 | "@rollup/plugin-node-resolve": "^16.0.0", 64 | "@rollup/plugin-virtual": "^3.0.2", 65 | "c8": "^10.1.3", 66 | "chai": "^5.2.0", 67 | "eslint": "^8.56.0", 68 | "eslint-config-riot": "^4.1.1", 69 | "jsdom": "26.0.0", 70 | "jsdom-global": "3.0.2", 71 | "mocha": "^11.1.0", 72 | "prettier": "^3.5.1", 73 | "riot": "^9.4.5", 74 | "rollup": "^4.34.8", 75 | "rollup-plugin-riot": "^9.0.2", 76 | "serve": "^14.2.4", 77 | "sinon": "^19.0.2", 78 | "sinon-chai": "^4.0.0" 79 | }, 80 | "peerDependency": { 81 | "riot": "^6.0.0 || ^7.0.0 || ^9.0.0" 82 | }, 83 | "dependencies": { 84 | "@riotjs/util": "^2.4.0", 85 | "bianco.attr": "^1.1.1", 86 | "bianco.events": "^1.1.1", 87 | "bianco.query": "^1.1.4", 88 | "cumpa": "^2.0.1", 89 | "rawth": "^3.0.0" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import riot from 'rollup-plugin-riot' 4 | import { resolve as nodeResolve } from 'node:path' 5 | import virtual from '@rollup/plugin-virtual' 6 | 7 | const standaloneExternal = [ 8 | nodeResolve('./src/components/route-hoc.riot'), 9 | nodeResolve('./src/components/router-hoc.riot'), 10 | ] 11 | 12 | const defaultOptions = { 13 | input: 'src/index.js', 14 | plugins: [resolve(), commonjs(), riot()], 15 | external: ['riot'], 16 | } 17 | 18 | const standalonePlugins = [ 19 | virtual( 20 | standaloneExternal.reduce( 21 | (acc, path) => ({ ...acc, [path]: 'export default {}' }), 22 | {}, 23 | ), 24 | ), 25 | ...defaultOptions.plugins, 26 | ] 27 | 28 | export default [ 29 | { 30 | ...defaultOptions, 31 | output: { 32 | format: 'esm', 33 | file: 'index.js', 34 | }, 35 | }, 36 | { 37 | ...defaultOptions, 38 | output: { 39 | format: 'umd', 40 | name: 'route', 41 | file: 'index.umd.js', 42 | }, 43 | }, 44 | { 45 | ...defaultOptions, 46 | plugins: standalonePlugins, 47 | output: { 48 | format: 'esm', 49 | file: 'index.standalone.js', 50 | }, 51 | }, 52 | { 53 | ...defaultOptions, 54 | plugins: standalonePlugins, 55 | output: { 56 | format: 'umd', 57 | name: 'route', 58 | file: 'index.standalone.umd.js', 59 | }, 60 | }, 61 | ] 62 | -------------------------------------------------------------------------------- /src/components/route-hoc.js: -------------------------------------------------------------------------------- 1 | import { PATH_ATTRIBUTE } from '../constants.js' 2 | import { 3 | route, 4 | toRegexp, 5 | match, 6 | router, 7 | createURLStreamPipe, 8 | } from '../index.js' 9 | import $ from 'bianco.query' 10 | import getCurrentRoute from '../get-current-route.js' 11 | import { get as getAttr } from 'bianco.attr' 12 | import { 13 | createDefaultSlot, 14 | getAttribute, 15 | isValidQuerySelectorString, 16 | } from '../util.js' 17 | import compose from 'cumpa' 18 | 19 | const getInitialRouteValue = (pathToRegexp, path, options) => { 20 | const route = compose( 21 | ...createURLStreamPipe(pathToRegexp, options).reverse(), 22 | )(path) 23 | 24 | return route.params ? route : null 25 | } 26 | 27 | const clearDOMBetweenNodes = (first, last, includeBoundaries) => { 28 | const clear = (node) => { 29 | if (!node || (node === last && !includeBoundaries)) return 30 | const { nextSibling } = node 31 | node.remove() 32 | clear(nextSibling) 33 | } 34 | 35 | clear(includeBoundaries ? first : first.nextSibling) 36 | } 37 | 38 | export const routeHoc = ({ slots, attributes }) => { 39 | const placeholders = { 40 | before: document.createTextNode(''), 41 | after: document.createTextNode(''), 42 | } 43 | 44 | return { 45 | mount(el, context) { 46 | // create the component state 47 | const currentRoute = getCurrentRoute() 48 | const path = 49 | getAttribute(attributes, PATH_ATTRIBUTE, context)?.evaluate(context) || 50 | getAttr(el, PATH_ATTRIBUTE) 51 | const pathToRegexp = toRegexp(path, []) 52 | const state = { 53 | pathToRegexp, 54 | route: 55 | currentRoute && match(currentRoute, pathToRegexp) 56 | ? getInitialRouteValue(pathToRegexp, currentRoute, {}) 57 | : null, 58 | } 59 | this.el = el 60 | this.slot = createDefaultSlot([ 61 | { 62 | isBoolean: false, 63 | name: 'route', 64 | evaluate: () => this.state.route, 65 | }, 66 | ]) 67 | this.context = context 68 | this.state = state 69 | // set the route listeners 70 | this.boundOnBeforeRoute = this.onBeforeRoute.bind(this) 71 | this.boundOnRoute = this.onRoute.bind(this) 72 | router.on.value(this.boundOnBeforeRoute) 73 | this.stream = route(path).on.value(this.boundOnRoute) 74 | // update the DOM 75 | el.replaceWith(placeholders.before) 76 | placeholders.before.parentNode.insertBefore( 77 | placeholders.after, 78 | placeholders.before.nextSibling, 79 | ) 80 | if (state.route) this.mountSlot() 81 | }, 82 | update(context) { 83 | this.context = context 84 | if (this.state.route) this.slot.update({}, context) 85 | }, 86 | mountSlot() { 87 | const { route } = this.state 88 | // insert the route root element after the before placeholder 89 | placeholders.before.parentNode.insertBefore( 90 | this.el, 91 | placeholders.before.nextSibling, 92 | ) 93 | this.callLifecycleProperty('onBeforeMount', route) 94 | this.slot.mount( 95 | this.el, 96 | { 97 | slots, 98 | }, 99 | this.context, 100 | ) 101 | this.callLifecycleProperty('onMounted', route) 102 | }, 103 | clearDOM(includeBoundaries) { 104 | // remove all the DOM nodes between the placeholders 105 | clearDOMBetweenNodes( 106 | placeholders.before, 107 | placeholders.after, 108 | includeBoundaries, 109 | ) 110 | }, 111 | unmount() { 112 | router.off.value(this.boundOnBeforeRoute) 113 | this.slot.unmount({}, this.context, true) 114 | this.clearDOM(true) 115 | this.stream.end() 116 | }, 117 | onBeforeRoute(path) { 118 | const { route } = this.state 119 | // this component was not mounted or the current path matches 120 | // we don't need to unmount this component 121 | if (!route || match(path, this.state.pathToRegexp)) return 122 | 123 | this.callLifecycleProperty('onBeforeUnmount', route) 124 | this.slot.unmount({}, this.context, true) 125 | this.clearDOM(false) 126 | this.state.route = null 127 | this.callLifecycleProperty('onUnmounted', route) 128 | }, 129 | onRoute(route) { 130 | const prevRoute = this.state.route 131 | this.state.route = route 132 | 133 | // if this route component was already mounted we need to update it 134 | if (prevRoute) { 135 | this.callLifecycleProperty('onBeforeUpdate', route) 136 | this.slot.update({}, this.context) 137 | this.callLifecycleProperty('onUpdated', route) 138 | } 139 | // this route component was never mounted, so we need to create its DOM 140 | else this.mountSlot() 141 | 142 | // emulate the default browser anchor links behaviour 143 | if (route.hash && isValidQuerySelectorString(route.hash)) 144 | $(route.hash)?.[0].scrollIntoView() 145 | }, 146 | callLifecycleProperty(method, ...params) { 147 | const attr = getAttribute(attributes, method, this.context) 148 | 149 | if (attr) attr.evaluate(this.context)(...params) 150 | }, 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/components/route-hoc.riot: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/router-hoc.js: -------------------------------------------------------------------------------- 1 | import { router } from '../index.js' 2 | import { defer, cancelDefer, getAttribute, createDefaultSlot } from '../util.js' 3 | import getCurrentRoute from '../get-current-route.js' 4 | import setBase from '../set-base.js' 5 | import { panic } from '@riotjs/util/misc' 6 | import initDomListeners from '../dom.js' 7 | 8 | const BASE_ATTRIBUTE_NAME = 'base' 9 | const INITIAL_ROUTE = 'initialRoute' 10 | const ON_STARTED_ATTRIBUTE_NAME = 'onStarted' 11 | 12 | export const routerHoc = ({ slots, attributes, props }) => { 13 | if (routerHoc.wasInitialized) 14 | panic('Multiple components are not supported') 15 | 16 | return { 17 | slot: null, 18 | el: null, 19 | teardown: null, 20 | mount(el, context) { 21 | const initialRouteAttr = getAttribute(attributes, INITIAL_ROUTE, context) 22 | const initialRoute = initialRouteAttr 23 | ? initialRouteAttr.evaluate(context) 24 | : null 25 | const currentRoute = getCurrentRoute() 26 | const onFirstRoute = () => { 27 | this.createSlot(context) 28 | router.off.value(onFirstRoute) 29 | } 30 | routerHoc.wasInitialized = true 31 | 32 | this.el = el 33 | this.teardown = initDomListeners(this.root) 34 | 35 | this.setBase(context) 36 | 37 | // mount the slots only if the current route was defined 38 | if (currentRoute && !initialRoute) { 39 | this.createSlot(context) 40 | } else { 41 | router.on.value(onFirstRoute) 42 | router.push(initialRoute || window.location.href) 43 | } 44 | }, 45 | createSlot(context) { 46 | if (!slots || !slots.length) return 47 | const onStartedAttr = getAttribute( 48 | attributes, 49 | ON_STARTED_ATTRIBUTE_NAME, 50 | context, 51 | ) 52 | 53 | this.slot = createDefaultSlot() 54 | 55 | this.slot.mount( 56 | this.el, 57 | { 58 | slots, 59 | }, 60 | context, 61 | ) 62 | 63 | if (onStartedAttr) { 64 | onStartedAttr.evaluate(context)(getCurrentRoute()) 65 | } 66 | }, 67 | update(context) { 68 | this.setBase(context) 69 | 70 | // defer the updates to avoid internal recursive update calls 71 | // see https://github.com/riot/route/issues/148 72 | if (this.slot) { 73 | cancelDefer(this.deferred) 74 | 75 | this.deferred = defer(() => { 76 | this.slot.update({}, context) 77 | }) 78 | } 79 | }, 80 | unmount(...args) { 81 | this.teardown() 82 | routerHoc.wasInitialized = false 83 | 84 | if (this.slot) { 85 | this.slot.unmount(...args) 86 | } 87 | }, 88 | getBase(context) { 89 | const baseAttr = getAttribute(attributes, BASE_ATTRIBUTE_NAME, context) 90 | 91 | return baseAttr 92 | ? baseAttr.evaluate(context) 93 | : this.el.getAttribute(BASE_ATTRIBUTE_NAME) || '/' 94 | }, 95 | setBase(context) { 96 | setBase(props ? props.base : this.getBase(context)) 97 | }, 98 | } 99 | } 100 | 101 | // flag to avoid multiple router instances 102 | routerHoc.wasInitialized = false 103 | -------------------------------------------------------------------------------- /src/components/router-hoc.riot: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const WINDOW_EVENTS = 'popstate' 2 | export const CLICK_EVENT = 'click' 3 | export const DOWNLOAD_LINK_ATTRIBUTE = 'download' 4 | export const HREF_LINK_ATTRIBUTE = 'href' 5 | export const TARGET_SELF_LINK_ATTRIBUTE = '_self' 6 | export const LINK_TAG_NAME = 'A' 7 | export const HASH = '#' 8 | export const SLASH = '/' 9 | export const PATH_ATTRIBUTE = 'path' 10 | export const RE_ORIGIN = /^.+?\/\/+[^/]+/ 11 | -------------------------------------------------------------------------------- /src/dom.js: -------------------------------------------------------------------------------- 1 | import { 2 | CLICK_EVENT, 3 | DOWNLOAD_LINK_ATTRIBUTE, 4 | HREF_LINK_ATTRIBUTE, 5 | LINK_TAG_NAME, 6 | RE_ORIGIN, 7 | TARGET_SELF_LINK_ATTRIBUTE, 8 | WINDOW_EVENTS, 9 | } from './constants.js' 10 | import { add, remove } from 'bianco.events' 11 | import { defaults, router } from 'rawth' 12 | import { getDocument, getHistory, getLocation, getWindow } from './util.js' 13 | import { has } from 'bianco.attr' 14 | 15 | const onWindowEvent = () => 16 | router.push(normalizePath(String(getLocation().href))) 17 | const onRouterPush = (path) => { 18 | const url = path.includes(defaults.base) ? path : defaults.base + path 19 | const loc = getLocation() 20 | const hist = getHistory() 21 | const doc = getDocument() 22 | 23 | // update the browser history only if it's necessary 24 | if (hist && url !== loc.href) { 25 | hist.pushState(null, doc.title, url) 26 | } 27 | } 28 | const getLinkElement = (node) => 29 | node && !isLinkNode(node) ? getLinkElement(node.parentNode) : node 30 | const isLinkNode = (node) => node.nodeName === LINK_TAG_NAME 31 | const isCrossOriginLink = (path) => 32 | path.indexOf(getLocation().href.match(RE_ORIGIN)[0]) === -1 33 | const isTargetSelfLink = (el) => 34 | el.target && el.target !== TARGET_SELF_LINK_ATTRIBUTE 35 | const isEventForbidden = (event) => 36 | (event.which && event.which !== 1) || // not left click 37 | event.metaKey || 38 | event.ctrlKey || 39 | event.shiftKey || // or meta keys 40 | event.defaultPrevented // or default prevented 41 | const isForbiddenLink = (el) => 42 | !el || 43 | !isLinkNode(el) || // not A tag 44 | has(el, DOWNLOAD_LINK_ATTRIBUTE) || // has download attr 45 | !has(el, HREF_LINK_ATTRIBUTE) || // has no href attr 46 | isTargetSelfLink(el) || 47 | isCrossOriginLink(el.href) 48 | const normalizePath = (path) => path.replace(defaults.base, '') 49 | const isInBase = (path) => !defaults.base || path.includes(defaults.base) 50 | 51 | /** 52 | * Callback called anytime something will be clicked on the page 53 | * @param {HTMLEvent} event - click event 54 | * @returns {undefined} void method 55 | */ 56 | const onClick = (event) => { 57 | if (isEventForbidden(event)) return 58 | 59 | const el = getLinkElement(event.target) 60 | 61 | if (isForbiddenLink(el) || !isInBase(el.href)) return 62 | 63 | event.preventDefault() 64 | 65 | router.push(normalizePath(el.href)) 66 | } 67 | 68 | /** 69 | * Link the rawth router to the DOM events 70 | * @param { HTMLElement } container - DOM node where the links are located 71 | * @returns {Function} teardown function 72 | */ 73 | export default function initDomListeners(container) { 74 | const win = getWindow() 75 | const root = container || getDocument() 76 | 77 | if (win) { 78 | add(win, WINDOW_EVENTS, onWindowEvent) 79 | add(root, CLICK_EVENT, onClick) 80 | } 81 | 82 | router.on.value(onRouterPush) 83 | 84 | return () => { 85 | if (win) { 86 | remove(win, WINDOW_EVENTS, onWindowEvent) 87 | remove(root, CLICK_EVENT, onClick) 88 | } 89 | 90 | router.off.value(onRouterPush) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/get-current-route.js: -------------------------------------------------------------------------------- 1 | import { router } from 'rawth' 2 | 3 | const getCurrentRoute = ((currentRoute) => { 4 | // listen the route changes events to store the current route 5 | router.on.value((r) => (currentRoute = r)) 6 | 7 | return () => { 8 | return currentRoute 9 | } 10 | })(null) 11 | 12 | export default getCurrentRoute 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import route, { 2 | createURLStreamPipe, 3 | match, 4 | router, 5 | toPath, 6 | toRegexp, 7 | toURL, 8 | configure, 9 | defaults, 10 | } from 'rawth' 11 | import Route from './components/route-hoc.riot' 12 | import Router from './components/router-hoc.riot' 13 | import getCurrentRoute from './get-current-route.js' 14 | import initDomListeners from './dom.js' 15 | import setBase from './set-base.js' 16 | 17 | export { 18 | defaults, 19 | configure, 20 | route, 21 | createURLStreamPipe, 22 | match, 23 | router, 24 | toPath, 25 | toRegexp, 26 | toURL, 27 | getCurrentRoute, 28 | initDomListeners, 29 | setBase, 30 | Router, 31 | Route, 32 | } 33 | -------------------------------------------------------------------------------- /src/set-base.js: -------------------------------------------------------------------------------- 1 | import { HASH, SLASH } from './constants.js' 2 | import { configure } from 'rawth' 3 | import { getWindow } from './util.js' 4 | 5 | export const normalizeInitialSlash = (str) => 6 | str[0] === SLASH ? str : `${SLASH}${str}` 7 | export const removeTrailingSlash = (str) => 8 | str[str.length - 1] === SLASH ? str.substr(0, str.length - 1) : str 9 | 10 | export const normalizeBase = (base) => { 11 | const win = getWindow() 12 | const loc = win.location 13 | const root = loc ? `${loc.protocol}//${loc.host}` : '' 14 | const { pathname } = loc ? loc : {} 15 | 16 | switch (true) { 17 | // pure root url + pathname 18 | case Boolean(base) === false: 19 | return removeTrailingSlash(`${root}${pathname || ''}`) 20 | // full path base 21 | case /(www|http(s)?:)/.test(base): 22 | return base 23 | // hash navigation 24 | case base[0] === HASH: 25 | return `${root}${pathname && pathname !== SLASH ? pathname : ''}${base}` 26 | // root url with trailing slash 27 | case base === SLASH: 28 | return removeTrailingSlash(root) 29 | // custom pathname 30 | default: 31 | return removeTrailingSlash(`${root}${normalizeInitialSlash(base)}`) 32 | } 33 | } 34 | 35 | export default function setBase(base) { 36 | configure({ base: normalizeBase(base) }) 37 | } 38 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import { dashToCamelCase } from '@riotjs/util/strings' 2 | import { isNil } from '@riotjs/util/checks' 3 | import { __ } from 'riot' 4 | 5 | export const getGlobal = () => getWindow() || global 6 | export const getWindow = () => (typeof window === 'undefined' ? null : window) 7 | export const getDocument = () => 8 | typeof document === 'undefined' ? null : document 9 | export const getHistory = () => 10 | typeof history === 'undefined' ? null : history 11 | export const getLocation = () => { 12 | const win = getWindow() 13 | return win ? win.location : {} 14 | } 15 | 16 | export const defer = (() => { 17 | const globalScope = getGlobal() 18 | 19 | return globalScope.requestAnimationFrame || globalScope.setTimeout 20 | })() 21 | 22 | export const cancelDefer = (() => { 23 | const globalScope = getGlobal() 24 | 25 | return globalScope.cancelAnimationFrame || globalScope.clearTimeout 26 | })() 27 | 28 | export const getAttribute = (attributes, name, context) => { 29 | if (!attributes) return null 30 | 31 | const normalizedAttributes = attributes.flatMap((attr) => 32 | isNil(attr.name) 33 | ? // add support for spread attributes https://github.com/riot/route/issues/178 34 | Object.entries(attr.evaluate(context)).map(([key, value]) => ({ 35 | // evaluate each value of the spread attribute and store it into the array 36 | name: key, 37 | // create a nested evaluate function pointing to the original value of the spread object 38 | evaluate: () => value, 39 | })) 40 | : attr, 41 | ) 42 | 43 | return normalizedAttributes.find((a) => dashToCamelCase(a.name) === name) 44 | } 45 | 46 | export const createDefaultSlot = (attributes = []) => { 47 | const { template, bindingTypes, expressionTypes } = __.DOMBindings 48 | 49 | return template(null, [ 50 | { 51 | type: bindingTypes.SLOT, 52 | name: 'default', 53 | attributes: attributes.map((attr) => ({ 54 | ...attr, 55 | type: expressionTypes.ATTRIBUTE, 56 | })), 57 | }, 58 | ]) 59 | } 60 | 61 | // True if the selector string is valid 62 | export const isValidQuerySelectorString = (selector) => 63 | /^([a-zA-Z0-9-_*#.:[\]\s>+~()='"]|\\.)+$/.test(selector) 64 | -------------------------------------------------------------------------------- /test/components.spec.js: -------------------------------------------------------------------------------- 1 | import { base, sleep } from './util.js' 2 | import HistoryRouterApp from './components/history-router-app.riot' 3 | import SpreadPropsRouter from './components/spred-props-router.riot' 4 | import NestedUpdates from './components/nested-updates.riot' 5 | import RecursiveUpdatesBugRouter from './components/recursive-updates-bug-router.riot' 6 | import StaticBasePath from './components/static-base-path.riot' 7 | import SameRouteMatches from './components/same-route-matches.riot' 8 | import ComputedRoutes from './components/computed-routes.riot' 9 | import { component } from 'riot' 10 | import { expect } from 'chai' 11 | import { router, defaults } from '../src/index.js' 12 | 13 | describe('components', function () { 14 | beforeEach(async function () { 15 | router.push('/') 16 | }) 17 | 18 | it('The router contents get properly rendered', async function () { 19 | const el = document.createElement('div') 20 | 21 | const comp = component(HistoryRouterApp)(el, { 22 | base, 23 | }) 24 | 25 | await sleep() 26 | 27 | expect(comp.$('p')).to.be.ok 28 | expect(comp.isRouterStarted).to.be.ok 29 | expect(comp.currentRoute).to.be.ok 30 | 31 | router.push('/goodbye/gianluca') 32 | 33 | await sleep() 34 | 35 | expect(comp.$('user p').innerHTML).to.be.equal('gianluca') 36 | expect(comp.$('h1').innerHTML).to.be.equal('Title') 37 | 38 | comp.unmount() 39 | }) 40 | 41 | it('The Router component accepts spread props', async function () { 42 | const el = document.createElement('div') 43 | 44 | const comp = component(SpreadPropsRouter)(el, { 45 | base, 46 | }) 47 | 48 | await sleep() 49 | 50 | expect(comp.$('p')).to.be.ok 51 | expect(comp.isRouterStarted).to.be.ok 52 | expect(comp.currentRoute).to.be.ok 53 | 54 | router.push('/goodbye/gianluca') 55 | 56 | await sleep() 57 | 58 | expect(comp.$('user p').innerHTML).to.be.equal('gianluca') 59 | expect(comp.$('h1').innerHTML).to.be.equal('Title') 60 | 61 | comp.unmount() 62 | }) 63 | 64 | it('The Route Context gets properly updated', async function () { 65 | const el = document.createElement('div') 66 | 67 | const comp = component(NestedUpdates)(el, { 68 | base, 69 | }) 70 | 71 | await sleep() 72 | 73 | expect(comp.$('p')).to.be.ok 74 | 75 | await sleep() 76 | 77 | expect(comp.$('user p').innerHTML).to.be.equal('goodbye') 78 | 79 | comp.unmount() 80 | }) 81 | 82 | it('Recursive onMounted callbacks (bug 148) ', async function () { 83 | const el = document.createElement('div') 84 | 85 | const comp = component(RecursiveUpdatesBugRouter)(el, { 86 | base, 87 | }) 88 | 89 | await sleep() 90 | 91 | expect(comp.$('p').innerHTML).to.be.equal('hello') 92 | 93 | await sleep() 94 | 95 | router.push('/') 96 | 97 | await sleep() 98 | 99 | expect(comp.$('p').innerHTML).to.be.equal('hello') 100 | 101 | comp.unmount() 102 | }) 103 | 104 | it('Static base path attributes are supported (bug 172) ', async function () { 105 | const el = document.createElement('div') 106 | const comp = component(StaticBasePath)(el) 107 | 108 | expect(defaults.base).to.be.equal('https://riot.rocks/app') 109 | 110 | comp.unmount() 111 | }) 112 | 113 | it('Routes matched multiple times do not render twice (bug 173) ', async function () { 114 | const el = document.createElement('div') 115 | const comp = component(SameRouteMatches)(el) 116 | 117 | expect(comp.$$('p')).to.have.length(1) 118 | 119 | router.push('/foo') 120 | 121 | await sleep() 122 | 123 | expect(comp.$$('p')).to.have.length(1) 124 | 125 | router.push('/') 126 | 127 | await sleep() 128 | 129 | expect(comp.$$('p')).to.have.length(1) 130 | 131 | comp.unmount() 132 | }) 133 | 134 | it('Computed routes get properly rendered', async function () { 135 | const el = document.createElement('div') 136 | const comp = component(ComputedRoutes)(el) 137 | 138 | expect(comp.$('p')).to.be.not.ok 139 | 140 | router.push('/home') 141 | 142 | await sleep() 143 | 144 | expect(comp.$('p')).to.be.ok 145 | 146 | await sleep() 147 | 148 | router.push('/') 149 | 150 | await sleep() 151 | 152 | expect(comp.$('p')).to.be.not.ok 153 | 154 | comp.unmount() 155 | }) 156 | }) 157 | -------------------------------------------------------------------------------- /test/components/computed-routes.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

{state.name}

5 |
6 |
7 | 21 |
22 | -------------------------------------------------------------------------------- /test/components/history-router-app.riot: -------------------------------------------------------------------------------- 1 | 2 |

{state.message}

3 | 4 | 5 |

Hello

6 |
7 | this.update({ 8 | message: 'Title' 9 | })}> 10 | 11 | 12 |
13 | 14 | 33 |
34 | -------------------------------------------------------------------------------- /test/components/nested-updates.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 25 | 26 | -------------------------------------------------------------------------------- /test/components/recursive-updates-bug-router.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 31 | 32 | -------------------------------------------------------------------------------- /test/components/recursive-updates-bug148.riot: -------------------------------------------------------------------------------- 1 | 2 |

{props.message}

3 | 10 |
-------------------------------------------------------------------------------- /test/components/same-route-matches.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

{state.message}

5 |
6 |
7 | 8 | 21 |
22 | -------------------------------------------------------------------------------- /test/components/spred-props-router.riot: -------------------------------------------------------------------------------- 1 | 2 |

{state.message}

3 | 4 | 5 |

Hello

6 |
7 | this.update({ 8 | message: 'Title' 9 | })}> 10 | 11 | 12 |
13 | 14 | 33 |
34 | -------------------------------------------------------------------------------- /test/components/static-base-path.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Home 5 | 6 | 7 |

{state.message}

8 |
9 |
10 | 11 | 24 |
25 | -------------------------------------------------------------------------------- /test/components/user.riot: -------------------------------------------------------------------------------- 1 | 2 |

{props.name}

3 | 4 | 13 |
-------------------------------------------------------------------------------- /test/misc.spec.js: -------------------------------------------------------------------------------- 1 | import { base, sleep } from './util.js' 2 | import { getCurrentRoute, router, setBase } from '../src/index.js' 3 | import { expect } from 'chai' 4 | import { normalizeBase } from '../src/set-base.js' 5 | 6 | describe('misc methods', function () { 7 | beforeEach(() => { 8 | setBase(`${base}#`) 9 | }) 10 | 11 | it('getCurrentRoute returns properly the current router value', async function () { 12 | router.push(`${base}#/hello`) 13 | 14 | await sleep() 15 | 16 | expect(getCurrentRoute()).to.be.equal(`${base}#/hello`) 17 | }) 18 | 19 | it('normalizeBase returns the expected paths', async function () { 20 | expect(normalizeBase('#')).to.be.equal(`${base}#`) 21 | expect(normalizeBase('/')).to.be.equal(`${base}`) 22 | expect(normalizeBase('')).to.be.equal(`${base}`) 23 | expect(normalizeBase('/hello')).to.be.equal(`${base}/hello`) 24 | expect(normalizeBase('hello')).to.be.equal(`${base}/hello`) 25 | expect(normalizeBase('http://google.com')).to.be.equal('http://google.com') 26 | expect(normalizeBase('/page#anchor')).to.be.equal(`${base}/page#anchor`) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { base } from './util.js' 2 | import { register } from 'node:module' 3 | import { pathToFileURL } from 'node:url' 4 | import jsdomGlobal from 'jsdom-global' 5 | import sinonChai from 'sinon-chai' 6 | import { use } from 'chai' 7 | 8 | register('@riotjs/register', pathToFileURL('./')) 9 | 10 | jsdomGlobal(null, { 11 | url: base, 12 | }) 13 | 14 | use(sinonChai) 15 | -------------------------------------------------------------------------------- /test/standalone-hash-dom.spec.js: -------------------------------------------------------------------------------- 1 | import { base, sleep } from './util.js' 2 | import { route, router, setBase } from '../src/index.js' 3 | import { expect } from 'chai' 4 | import { spy } from 'sinon' 5 | 6 | describe('standalone hash', function () { 7 | beforeEach(() => { 8 | setBase('#') 9 | }) 10 | 11 | afterEach(() => { 12 | window.history.replaceState(null, '', '/') 13 | }) 14 | 15 | it('hash links dispatch events', async function () { 16 | const onRoute = spy() 17 | const hello = route('/hello').on.value(onRoute) 18 | 19 | router.push(`${base}#/hello`) 20 | 21 | await sleep() 22 | 23 | expect(onRoute).to.have.been.called 24 | hello.end() 25 | }) 26 | 27 | it('hash links receive parameters', (done) => { 28 | const user = route('/user/:username').on.value((url) => { 29 | user.end() 30 | expect(url.params).to.be.deep.equal({ username: 'gianluca' }) 31 | done() 32 | }) 33 | 34 | router.push(`${base}#/user/gianluca`) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/standalone-history-dom.spec.js: -------------------------------------------------------------------------------- 1 | import { fireEvent, sleep } from './util.js' 2 | import { initDomListeners, route, setBase } from '../src/index.js' 3 | import $ from 'bianco.query' 4 | import { expect } from 'chai' 5 | import { spy } from 'sinon' 6 | 7 | describe('standalone history', function () { 8 | let teardown // eslint-disable-line 9 | 10 | beforeEach(() => { 11 | setBase('/') 12 | 13 | document.body.innerHTML = ` 14 | 21 | ` 22 | teardown = initDomListeners($('nav')[0]) 23 | }) 24 | 25 | afterEach(() => { 26 | document.body.innerHTML = '' 27 | window.history.replaceState(null, '', '/') 28 | teardown() 29 | }) 30 | 31 | it('html5 history links dispatch events', async function () { 32 | const onRoute = spy() 33 | const hello = route('/hello').on.value(onRoute) 34 | 35 | const [a] = $('nav > a:first-of-type') 36 | 37 | fireEvent(a, 'click') 38 | 39 | await sleep() 40 | 41 | expect(window.location.pathname).to.be.equal('/hello') 42 | expect(onRoute).to.have.been.called 43 | 44 | hello.end() 45 | }) 46 | 47 | it('html5 history links receive parameters', (done) => { 48 | const user = route('/user/:username').on.value((url) => { 49 | user.end() 50 | expect(url.params).to.be.deep.equal({ username: 'gianluca' }) 51 | done() 52 | }) 53 | 54 | const [a] = $('nav > a:nth-child(4)') 55 | 56 | fireEvent(a, 'click') 57 | }) 58 | 59 | it('hash links are supported', async () => { 60 | const onRoute = spy() 61 | const hello = route('/hello(/?[?#].*)?').on.value(onRoute) 62 | 63 | const [a] = $('nav > a:nth-child(5)') 64 | 65 | fireEvent(a, 'click') 66 | 67 | await sleep() 68 | 69 | expect(onRoute).to.have.been.called 70 | expect(window.location.pathname).to.be.equal('/hello') 71 | expect(window.location.hash).to.be.equal('#anchor') 72 | 73 | hello.end() 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | export function fireEvent(el, name) { 2 | const e = new Event(name, { bubbles: true, cancelable: false }) 3 | 4 | el.dispatchEvent(e) 5 | } 6 | 7 | export const sleep = (timeout) => new Promise((r) => setTimeout(r, timeout)) 8 | 9 | export const base = 'https://riot.rocks' 10 | --------------------------------------------------------------------------------