├── .gitattributes ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bower.json ├── docs ├── README.md ├── basic.md ├── best-practices.md ├── context.md ├── middleware.md ├── nested.md ├── path-binding.md ├── plugins.md ├── router.md ├── typescript.md └── utils.md ├── examples ├── .babelrc ├── .eslintrc ├── README.md ├── hashbang │ ├── index.html │ └── index.js ├── index.html ├── lazy-loading │ ├── index.html │ ├── index.js │ ├── plugins │ │ └── lazy-component-loader.js │ └── views │ │ ├── bar │ │ ├── index.js │ │ └── template.html │ │ ├── baz │ │ ├── index.js │ │ └── template.html │ │ ├── foo │ │ ├── index.js │ │ └── template.html │ │ ├── list │ │ ├── index.js │ │ └── template.html │ │ └── qux │ │ ├── index.js │ │ └── template.html ├── loading-animation │ ├── index.html │ ├── index.js │ ├── middleware │ │ └── loading.js │ └── views │ │ ├── bar │ │ ├── index.js │ │ └── template.html │ │ └── foo │ │ ├── index.js │ │ └── template.html ├── mvc │ ├── components │ │ ├── user-card │ │ │ ├── index.js │ │ │ ├── template.html │ │ │ └── viewmodel.js │ │ └── user-editor │ │ │ ├── index.js │ │ │ ├── template.html │ │ │ └── viewmodel.js │ ├── controllers │ │ ├── edit.js │ │ ├── list.js │ │ ├── new.js │ │ └── show.js │ ├── index.html │ ├── index.js │ ├── models │ │ └── user.js │ ├── plugins │ │ ├── component.js │ │ └── middleware.js │ ├── routes.js │ ├── utils │ │ └── id-generator.js │ └── views │ │ ├── edit │ │ ├── index.js │ │ ├── template.html │ │ └── viewmodel.js │ │ ├── list │ │ ├── index.js │ │ ├── template.html │ │ └── viewmodel.js │ │ ├── new │ │ ├── index.js │ │ ├── template.html │ │ └── viewmodel.js │ │ └── show │ │ ├── index.js │ │ ├── template.html │ │ └── viewmodel.js ├── package.json ├── path-binding │ ├── index.html │ └── index.js ├── simple-auth │ ├── index.html │ └── index.js ├── webpack.config.js └── yarn.lock ├── ko-component-router.js ├── ko-component-router.min.js ├── package.json ├── src ├── bindings │ ├── active-path.ts │ ├── index.ts │ └── path.ts ├── component.ts ├── context.ts ├── index.ts ├── route.ts ├── router.ts └── utils.ts ├── taskfile.js ├── tasks ├── .eslintrc ├── bundle.js ├── compile.js ├── stats.js └── test.js ├── test ├── .eslintrc ├── anchor.js ├── basepath.js ├── before-navigate-callbacks.js ├── bindings │ ├── active-path.js │ ├── index.js │ └── path.js ├── force-update.js ├── hashbang.js ├── helpers │ └── ko-overwrite-component-registration.js ├── history.js ├── index.js ├── middleware.js ├── plugins.js ├── queue.js ├── redirect.js ├── routing │ ├── ambiguous.js │ ├── basic.js │ ├── index.js │ ├── init.js │ ├── nested.js │ ├── params.js │ └── similar.js └── with.js ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | ko-component-router.js linguist-generated=true 2 | ko-component-router.min.js linguist-generated=true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | *.log 4 | 5 | .cache/ 6 | .chrome/ 7 | .vscode/ 8 | coverage/ 9 | dist/ 10 | node_modules/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | addons: 5 | firefox: latest 6 | env: 7 | matrix: 8 | - TRAVIS=true 9 | global: 10 | secure: LHF20/U42BskU7ov0yH0PaOjOmVVP8kNwJTCVshOhEcJNJwTt1LvBSX7DxiIfxOntgLu0kZLIHjyPuESjeJYLP+1BlfACuXfg/CC2X9C1cbsoxbkF7vhpqIq7scEUW0h6d0OpxVzBzyjjYyxE14ct1KGVrylqnwrbAD5EsRPM3Byf5WFq1iMd2VWxvwn1KnluKm+7EklbsPC6X93ui7o8gDllwl55vZjw2a+JnymAoORbk5k07Vo4/5hRI4wLNEZ2hPD2FnWc30Tir/WrDU9/b/by+JLr8J+vTuAH13WdNRJ7oe1bggIahObEIj6D9RyjizO9EeUVNF7g64VbOK1aGWPYzGKCMojPadhs6rzkFoKCC6xWBMidfqKms3dkF0JE4GJVcKd0XO6AbGfdomK7TrQNh54jN4cpUhmEhGMAUS05qG9+hrCqiqVULm1A0TqFnZPzrm+jA7XLgxUk0CAYHfv17XgAdi9aeubmAD4vXAquSvPnbAiDQbH3B73k6Vk1V9fxiqu5NU83OPt8D0X+TiAhhEOIgMlCHpQr71NT3c0C1DbTFZvQmZ6LFdIz0hJwNVF+MJwnE4YUHGY0hjXbZndWfhksEtZuNlGmeEVlNrF9d6qEEfOpoeMxDDRuKO9GPk/A/lQSJXqommbhpMNBWGxlG2e5dK9liYt95UyOBI= 11 | cache: 12 | yarn: true 13 | directories: 14 | - node_modules 15 | before_install: 16 | - yarn global add codecov greenkeeper-lockfile@1 17 | before_script: 18 | - export DISPLAY=:99.0 19 | - sh -e /etc/init.d/xvfb start 20 | - greenkeeper-lockfile-update 21 | after_script: 22 | - codecov 23 | - greenkeeper-lockfile-upload 24 | notifications: 25 | email: false 26 | webhooks: 27 | urls: 28 | - https://webhooks.gitter.im/e/76b4266a7bbf95521f37 29 | on_success: change 30 | on_failure: always 31 | on_start: never 32 | slack: 33 | secure: jLM26zBxuZZS71duWH1Bt1GrdEfMLOqNJWnyuObsuproBRQCmhzNWVd+qDEv+fcmZn8DPWr9gCj61RA8QpfEV8UOmS8HIfAjhGUGm1iWY3vkwcYxABq/mJdmJNFM29f3jY5cjBzCbAfPqWKuoswcqZ7ZPr2TE6wxGNr1Pc9x9Bq5j1wW6j1Kbj9ru/6NlOv2wO6XzkM34tEjeHrYmi/kS3756dLCqxOWuQx2sgCVxmU6ad9BsAOMJgJ9rJHI1qiTybI2BkUe5T8DXFUaHwLl1LRlYZCZol01vc/vv8LyxYRRW2f/UkL5XZ7BMxR3U/8ybnvulo0TbLnVWSCD4W2l+DGGPQtKASRrkxxzWlrHAQpzutsrZfQi5vkPgz2fu8JMIH32VTS01SoVlRH0TUIRfXO7EqJr+RziqxfLCXiAuMtWuey+8iWH/8PjE/hQCFON5VDFOBftdEZvIzRNoMtX8xwgco7SMLGWZfc5kdELt8OJ+qKZoBKxdgHG9/KcywKr0KMQvfFYwB3XaPOR0VWnwkzYWiZu84BRpAR4LkhDY+UnGEVFhOOpisfgtWO16DBft2Mv/PdUcQITxIXWK00SOpXDHG7s74gsrgs+mcJ08NUVlipp7mDVvTYm8TrMzeR/ZqaeL9NuojqxbxeuLuUHZB+3c1dkiThda83jiRCnne4= 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 5.0.1 4 | 5 | ### Fixed 6 | - Incorrect tslib dependency 7 | 8 | 9 | ## 5.0.0 10 | 11 | ### Changed 12 | - Improved nested routing UX ([Building a Better Router](https://medium.com/@notCaseyWebb/building-a-better-router-ef42896e2e5a)) 13 | 14 | ### Removed 15 | - Ability to pass routes as component parameters to nested routers 16 | 17 | 18 | ## 4.4.4 19 | 20 | ### Fixed 21 | - deeply nested child afterDispose middleware not being called 22 | 23 | 24 | ## 4.4.3 25 | 26 | ### Fixed 27 | - querystring and hash being wiped out in child routes on popstate 28 | - child afterDispose middleware not being called 29 | 30 | 31 | ## 4.4.2 32 | 33 | ### Fixed 34 | - Navigating to same route w/ different params caused issues w/ middleware execution order — #164 35 | 36 | 37 | ## 4.4.1 38 | 39 | ### Added 40 | - examples! :beers: 41 | 42 | ### Fixed 43 | - Basepath not working correctly with hashbang (appended instead of prepended) — #156 44 | - Landing on `/` in hashbang mode did not redirect to `/#!/` 45 | 46 | 47 | ## 4.4.0 48 | 49 | ### Added 50 | - Router.initialized 51 | 52 | ### Fixed 53 | - afterRender middleware in child routers being ran twice 54 | - path binding not usable from parent component when wrapped — #157 55 | 56 | 57 | ## 4.3.1 58 | 59 | ### Fixed 60 | - Navigating to url w/ query or hash wiped out said query or hash 61 | 62 | 63 | ## 4.3.0 64 | 65 | ### Added 66 | - Router.setConfig 67 | - Router.useRoutes 68 | - router.$parents 69 | - ctx.$parents 70 | - router.$children 71 | - ctx.$children 72 | 73 | ### Fixed 74 | - Options to router.update were not being passed down to children when applicable 75 | - Child afterRender middleware was not being ran when parent navigated away 76 | 77 | 78 | ## 4.2.0 79 | 80 | ### Added 81 | - `with` option for router.update 82 | 83 | 84 | ## 4.1.0 85 | 86 | ### Added 87 | - Router.head and Router.tail accessors 88 | 89 | 90 | ## 4.0.1 91 | 92 | ### Changed 93 | - Middleware execution order; beforeRender middleware is now called _before_ 94 | preceding page's afterRender, preventing a blank page while async middleware is 95 | executing 96 | 97 | ### Fixed 98 | - IE9-11 pathname parsing [#132](https://github.com/Profiscience/ko-component-router/pull/132) 99 | 100 | 101 | ## 4.0.0 102 | See [Migrating from 3.x to 4.x](https://github.com/Profiscience/ko-component-router/wiki/Migrating-from-3.x-to-4.x) 103 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2017 Casey Webb 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ko-component-router 2 | 3 | This repository has been moved to [@profiscience/knockout-contrib-router][]. See the [CHANGELOG][] for all changes. 4 | 5 | ### Migrating 6 | 7 | - `yarn remove ko-component-router` 8 | - `yarn add @profiscience/knockout-contrib-router` 9 | - Rename all `` elements to `` 10 | - Rename all imports (`import { Router } from 'ko-component-router'` => `import { Router } from '@profiscience/knockout-contrib-router'`) 11 | 12 | [CHANGELOG]: https://github.com/Profiscience/knockout-contrib/blob/master/packages/router/CHANGELOG.md#100-ko-component-router--profiscienceknockout-contrib-router 13 | [@profiscience/knockout-contrib-router]: https://github.com/Profiscience/knockout-contrib/tree/master/packages/router -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ko-component-router", 3 | "description": "Component-based routing for KnockoutJS", 4 | "main": "ko-component-router.js", 5 | "authors": [ 6 | "Casey Webb " 7 | ], 8 | "license": "WTFPL", 9 | "keywords": [ 10 | "knockoutjs", 11 | "knockout", 12 | "ko", 13 | "component", 14 | "router", 15 | "routing", 16 | "spa" 17 | ], 18 | "homepage": "https://github.com/Profiscience/ko-component-router", 19 | "moduleType": [ 20 | "amd", 21 | "globals", 22 | "node" 23 | ], 24 | "ignore": [ 25 | "**/.*", 26 | "coverage", 27 | "node_modules", 28 | "bower_components", 29 | "test" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | #### Table of Contents 2 | - [Basic Usage](./basic.md) 3 | - [Context](./context.md) 4 | - [Router](./router.md) 5 | - [Middleware](./middleware.md) 6 | - [Nested Routing](./nested.md) 7 | - [Path Binding](./path-binding.md) 8 | - [Plugins](./plugins.md) 9 | - [Best Practices](./best-practices.md) 10 | - [TypeScript Support](./typescript.md) 11 | - [Wiki (Usage Examples)](https://github.com/Profiscience/ko-component-router/wiki) 12 | 13 | #### Further Reading 14 | - [Ali (KnockoutJS SPA Boilerplate)](https://github.com/caseyWebb/ali) 15 | - [Building a Better Router](https://medium.com/@notCaseyWebb/building-a-better-router-ef42896e2e5a) 16 | -------------------------------------------------------------------------------- /docs/basic.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | ```bash 3 | $ npm install ko-component-router 4 | ``` 5 | 6 | The following browser features are required: 7 | 8 | | Feature | Browser Support | Polyfill | 9 | | ------------- | ------------------- | ------------------------------------- | 10 | | Promises/A+ | [Link][promise] | [es6-promise][promise-polyfill] | 11 | | History | [Link][history] | [html5-history-api][history-polyfill] | 12 | 13 | If using the above HTML5 history polyfill, be sure to configure the polyfill after loading; 14 | the polyfill must have the `!` path prefix registered via: 15 | 16 | ```javascript 17 | window.history.setup('/', '!/', null) 18 | ``` 19 | 20 | ## Configuration 21 | 22 | Configuration is set using the static `.setConfig` method on the `Router` class 23 | 24 | ```javascript 25 | import { Router } from 'ko-component-router' 26 | 27 | Router.setConfig({ 28 | // base path app runs under, i.e. '/app' 29 | base: '', 30 | 31 | // use legacy hashbang routing (History API or polyfill still required) 32 | hashbang: false, 33 | 34 | // CSS class added to elements with a path binding that resolves to the current 35 | // page — useful for styling navbars and tabs 36 | activePathCSSClass: 'active-path' 37 | }) 38 | ``` 39 | 40 | ## Registering Routes 41 | 42 | Routes are registered using the static `.useRoutes` method on the Router. 43 | 44 | Routes are objects with [express style routes](https://github.com/pillarjs/path-to-regexp) 45 | as keys, and a(n) 46 | a) component name for the view 47 | b) [middleware](./middleware.md) function 48 | c) [nested route](./nested-routing.md) config 49 | d) array containing any combination of the above 50 | 51 | This is merely the default syntax, and you are encouraged to use [plugins](./plugins.md) 52 | to set up an architecture that makes sense for your app. For more on this, see the 53 | [best practices](./best-practices.md) or check out [Ali](https://github.com/caseyWebb/ali). 54 | 55 | Component viewModels and middleware functions will receive a [context](./context.md) 56 | object that contains information such as the route params, current path information, 57 | and any data attached via middleware as their first argument. 58 | 59 | If a picture is worth 1k words, code is worth 1M... 60 | 61 | ```javascript 62 | import { Router } from 'ko-component-router' 63 | 64 | Router.useRoutes({ 65 | routes: { 66 | '/': 'home', 67 | 68 | '/users': { 69 | '/': [loadUsers, 'user-list'], 70 | 71 | '/:id': [loadUser, { 72 | '/': 'user-show', 73 | '/edit': 'user-edit' 74 | }] 75 | } 76 | } 77 | }) 78 | 79 | ko.components.register('home', { 80 | template: '' 81 | }) 82 | 83 | ko.components.register('user-list', { 84 | viewModel: class UserList { 85 | constructor(ctx) { 86 | this.users = ctx.users 87 | } 88 | }, 89 | template: ` 90 | 93 | ` 94 | }) 95 | 96 | ko.components.register('user', { 97 | viewModel: class User { 98 | constructor(ctx) { 99 | console.log(ctx.params.userID) 100 | // 1234 101 | } 102 | }, 103 | template: '...' 104 | }) 105 | 106 | ko.applyBindings() 107 | ``` 108 | 109 | ```html 110 | 111 | 112 | 113 | ``` 114 | --- 115 | 116 | [Back](./README.md) 117 | 118 | [promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#Browser_compatibility "MDN - Promise" 119 | [promise-polyfill]: https://github.com/stefanpenner/es6-promise "es6-promise" 120 | [history]: https://developer.mozilla.org/en-US/docs/Web/API/History_API#Browser_compatibility "MDN - History API" 121 | [history-polyfill]: https://github.com/devote/HTML5-History-API "HTML5-History-API" 122 | -------------------------------------------------------------------------------- /docs/best-practices.md: -------------------------------------------------------------------------------- 1 | # Best Practices 2 | 3 | Use plugins liberally to set up an architecture for your app. For example, create 4 | plugins for... 5 | - setting the document title 6 | - creating breadcrumbs (with nested routing) 7 | - creating/attaching a scoped [querystring](https://github.com/Profiscience/ko-querystring) object 8 | - loading components on demand 9 | - loading data 10 | - loading/disposing styles 11 | 12 | --- 13 | 14 | Abstract your data loading into middleware or a plugin. This makes dealing with data across 15 | nested routers painless, and prevents you from needing to implement a loading animation on 16 | each page. Instead, handle loading animation via another middleware using lifecycle events — 17 | start the loader at the beginning of the middleware execution in `beforeRender`, 18 | and stop it in the `afterRender`. 19 | 20 | For a contrived example of this, you could create a data plugin that enables you 21 | to have route configs like... 22 | 23 | ```javascript 24 | export default { 25 | component: 'component-name', 26 | api: '/api/some/api/endpoint' 27 | } 28 | ``` 29 | 30 | Your `api` plugin could then accumulate any necessary params from `ctx.params` and 31 | `ctx.$parents` params and attach the result to ctx. This is then available in the view 32 | as well as children (unless queued) and the data will be fetched before navigation occurs, 33 | meaning no intermediate white space. 34 | 35 | When combined with a [querystring](https://github.com/Profiscience/ko-querystring), 36 | you can achieve very little boilerplate, convention-over-configuration views that 37 | *just have* their data with minimal effort from you. 38 | 39 | Note, this is a contrived example. A better method is to create a model class and a 40 | plugin that understands it. In our case, views look like... 41 | 42 | ```javascript 43 | const COURSE_TYPES = new Map([ 44 | ['/', 0], 45 | ['/on-demand', 1], 46 | ['/live', 2], 47 | ['/assessment', 4] 48 | ]) 49 | 50 | export default { 51 | query: { 52 | searchText: '', 53 | sort: 'p' 54 | }, 55 | routes: { 56 | '/:type': { 57 | query: { 58 | createQueryFactory(ctx) { 59 | const q = ctx.$parent.query 60 | q.set({ courseType: COURSE_TYPES.get(ctx.pathname) }) 61 | return q 62 | } 63 | }, 64 | collection: () => import('./collection'), 65 | component: () => ({ 66 | template: import('./Courses.html'), 67 | viewModel: import('./Courses') 68 | }) 69 | } 70 | } 71 | } 72 | ``` 73 | 74 | With `collection.js` being 75 | 76 | ```javascript 77 | import { collectionConstructorFactory } from 'utils/collection' 78 | 79 | export default collectionConstructorFactory({ api: '/api/courses' }) 80 | ``` 81 | 82 | Which sets up a class with a static `.factory(params)` function, and the 83 | collection plugin — `collection: () => import('./collection')` — is responsible 84 | for calling the function that 85 | a) asynchronously loads the model js file via webpack & `import()` 86 | b) calls the `factory` function with the route params and query 87 | c) attaches the instantiated collection to `ctx.collection` 88 | 89 | --- 90 | 91 | Use `synchronous: true` on your view components. 92 | 93 | --- 94 | 95 | For large SPAs, use the router to load assets on demand via middleware/plugins. This can 96 | be seen above with the functions that return a promise via `import()`. 97 | 98 | --- 99 | 100 | The router plays nice with `ko.options.deferUpdates`, and its usage is highly recommended. 101 | 102 | --- 103 | 104 | Check out the [examples](../examples)! 105 | 106 | --- 107 | 108 | [Back](./README.md) 109 | -------------------------------------------------------------------------------- /docs/context.md: -------------------------------------------------------------------------------- 1 | # Context 2 | 3 | The context object contains information about the current route as well as any 4 | data attached by [middleware](./middleware.md) (if applicable) 5 | 6 | ## API 7 | 8 | #### ctx.router 9 | Router that the current page belongs to 10 | 11 | #### ctx.route 12 | Current route 13 | 14 | #### ctx.base 15 | Parent pathname and app base, if applicable 16 | 17 | #### ctx.path 18 | The current path, excluding parent path, including child path 19 | 20 | #### ctx.pathname 21 | The current path, excluding parent path and child path 22 | 23 | #### ctx.canonicalPath 24 | The current path, including parent, excluding app base, excluding child path 25 | 26 | #### ctx.params 27 | Object containing route parameters 28 | 29 | #### ctx.$root 30 | Root context accessor 31 | 32 | #### ctx.$parent 33 | Parent context accessor 34 | 35 | #### ctx.$parents 36 | Array of parent contexts 37 | 38 | #### ctx.$child 39 | Child context accessor 40 | 41 | #### ctx.$children 42 | Array of child contexts 43 | 44 | #### ctx.addBeforeNavigateCallback(([done]) => done(boolean|null) | Promise) 45 | Adds a function to be executed before the page is navigated away from, and potentially 46 | block navigation. This may be used to show a save confirmation, for example. 47 | 48 | Async is supported via promises or an optional `done` callback. 49 | 50 | To prevent navigation, the beforeNavigate callback may 51 | a) return `false` 52 | b) return a Promise that resolves `false` 53 | c) return a rejected Promise 54 | d) call the optional `done` callback with `false` 55 | 56 | Callbacks are executed LIFO; async functions will be ran in series. If a callback 57 | prevents navigation by one of the above methods, no more callbacks will be executed. 58 | 59 | e.x. 60 | 61 | ```javascript 62 | import ko from 'knockout' 63 | import swal from 'sweetalert2' 64 | 65 | class ViewModel { 66 | constructor(ctx) { 67 | this.savePending = ko.observable(false) 68 | ctx.addBeforeNavigateCallback(this.promptToSave.bind(this)) 69 | } 70 | 71 | // async functions are just functions that return promises behind the scenes 72 | async promptToSave() { 73 | if (!this.savePending()) { 74 | return false 75 | } 76 | 77 | try { 78 | await swal({ 79 | title: 'Save Pending!', 80 | text: 'Are you sure you want to leave this page?', 81 | type: 'warning', 82 | showCancelButton: true, 83 | confirmButtonText: 'Discard Changes' 84 | }) 85 | } catch (e) { 86 | return false 87 | } 88 | } 89 | } 90 | ``` 91 | 92 | #### ctx.queue(promise) 93 | Queues a promise and allows middleware to continue running, but still resolves 94 | before the page is rendered. Allows for many async operations that do not depend 95 | on each other to run concurrently. For an example, see the [lazy-loading](../examples/lazy-loading) 96 | example and open the network tab. Each child loads its component via ajax, however 97 | all of these requests are made at the same time and do not prevent other middleware 98 | from executing. 99 | 100 | #### ctx.redirect(path) 101 | Only to be used in plain middleware, or the `beforeRender` of a lifecycle middleware, 102 | this function short circuits the middleware execution, prevents an intermediate render 103 | and runs and downstream lifecycle middleware to clean up, then navigates to a new route. 104 | 105 | `path` is resolved via [utils.traversePath](./utils.md#traversePath) 106 | 107 | --- 108 | 109 | [Back](./README.md) 110 | -------------------------------------------------------------------------------- /docs/middleware.md: -------------------------------------------------------------------------------- 1 | # Middleware 2 | 3 | The real power and extensibility of the router comes in the form of middleware. 4 | In this case, middleware is a series of functions, sync or async, that compose a 5 | route. 6 | 7 | If used correctly, you can have complete control over the lifecycle of each view 8 | and keep your viewModel as slim as possible (think skinny controllers, fat models). 9 | 10 | ## Registering Middleware 11 | 12 | ### App 13 | 14 | App middleware is ran for every route and is registered using `Router.use` 15 | 16 | ```javascript 17 | import { Router } from 'ko-component-router' 18 | 19 | Router.use(fn) 20 | ``` 21 | 22 | ### Route 23 | 24 | First, let's look at some code... 25 | 26 | This... 27 | 28 | ```javascript 29 | { 30 | '/user/:id': 'user' 31 | } 32 | ``` 33 | 34 | ...is really just shorthand for... 35 | 36 | ```javascript 37 | { 38 | '/user/:id': ['user'] 39 | } 40 | ``` 41 | 42 | ...which is _really_ just shorthand for... 43 | 44 | ```javascript 45 | { 46 | '/user/:id': [(ctx) => ctx.route.component = 'user'] 47 | } 48 | ``` 49 | 50 | ...so with that in mind, let's talk route middleware. 51 | 52 | As you can — hopefully — see, each route boils down to an array of functions: middleware. 53 | 54 | To add middleware to a route, simply add it to the array... 55 | 56 | ```javascript 57 | { 58 | '/user/:id': [ 59 | fn, 60 | 'user' 61 | ] 62 | } 63 | ``` 64 | 65 | __NOTE:__ Putting functions after the component will *not* cause the functions 66 | to run after the component is rendered. For how to accomplish that, keep reading. 67 | 68 | ## Middleware Functions 69 | 70 | Middleware functions are passed 2 arguments: 71 | - `ctx`: the ctx object passed into the viewmodel 72 | - `done`: an optional callback for async functions\*; promises are also supported, and encouraged 73 | 74 | \*that should wait for completion before continuing middleware, otherwise use `ctx.queue()` 75 | 76 | Let's look at some example logging middleware... 77 | 78 | ```javascript 79 | import { Router } from 'ko-component-router' 80 | 81 | Router.use((ctx) => console.log('[router] navigating to', ctx.pathname)) 82 | ``` 83 | 84 | But wait, there's more! 85 | 86 | Take our users route from earlier, and let's posit that you're trying to refactor 87 | your data calls out of the viewmodel... 88 | 89 | ```javascript 90 | { 91 | '/user/:id': [ 92 | (ctx) => getUser().then((u) => ctx.user = u), 93 | 'user' 94 | ] 95 | } 96 | ``` 97 | 98 | In the viewmodel for the `user` component, `ctx.user` will contain the user. Since 99 | we're returning a promise, the next middleware (in this case the component setter) 100 | will not be executed until after the call has completed. If you wished to continue 101 | middleware execution immediately, but still ensure any asynchronous operations 102 | have completed before render, you could use `ctx.queue`. 103 | 104 | Let's see how we can take some finer control. As has been the theme, you've got options... 105 | 106 | ### Lifecycle Object 107 | You can return an object from your middleware that contains functions to be executed 108 | at different points in the page lifecycle. 109 | 110 | ```javascript 111 | import Query from 'ko-query' 112 | 113 | export default function(ctx) { 114 | return { 115 | beforeRender(/* done */) { 116 | console.log('[router] navigating to', ctx.pathname) 117 | ctx.query = new Query({}, ctx.pathname) 118 | 119 | return loadSomeAsyncData.then((data) => { 120 | ctx.data = data 121 | 122 | // callbacks are also supported 123 | // done() 124 | }) 125 | }, 126 | afterRender() { 127 | console.log('[router] navigated to', ctx.pathname) 128 | }, 129 | beforeDispose() { 130 | console.log('[router] navigating away from', ctx.pathname) 131 | }, 132 | afterDispose() { 133 | console.log('[router] navigated away from', ctx.pathname) 134 | } 135 | } 136 | } 137 | ``` 138 | 139 | You may be wondering, "why a function returning an object instead of just an object?" 140 | 141 | Well, if you read the docs on nested routing, you'll see that you can define routes 142 | by passing an object to a route. To avoid _too much_ polymorphism that could cause 143 | confusion, this was the ideal approach. It also enables dynamic middleware and 144 | more meta-programming opportunities. 145 | 146 | ### Generator Middleware 147 | Now for the real fun — in my humble opinion, of course —, generator middleware. 148 | 149 | If you're unfamiliar with generators, [read up](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*), 150 | but fear not. In short, they are functions that are able to suspend and resume 151 | execution. 152 | 153 | Let's write the same `monolithicMiddleware` with a generator, then walk through what is going on... 154 | 155 | ```javascript 156 | import { Router } from 'ko-component-router' 157 | import Query from 'ko-query' 158 | 159 | function * monolithicMiddleware(ctx) { 160 | console.log('[router] navigating to', ctx.pathname) 161 | ctx.query = new Query({}, ctx.pathname) 162 | yield loadSomeAsyncData().then((data) => ctx.data = data) 163 | 164 | console.log('[router] navigated to', ctx.pathname) 165 | yield 166 | 167 | console.log('[router] navigating away from', ctx.pathname) 168 | yield 169 | 170 | console.log('[router] navigated away from', ctx.pathname) 171 | ctx.query.dispose() 172 | } 173 | 174 | Router.use(monolithicMiddleware) 175 | ``` 176 | 177 | _Hopefully_ it's pretty obvious what is going on here, but if not, I'll elaborate. 178 | 179 | Generator middleware is expected to yield up to 3 times, and will be resumed at 180 | the same points in the lifecycle: beforeRender, afterRender, beforeDispose, and afterDispose. 181 | 182 | Function entry to the first `yield` contains logic to be executed before the component 183 | is initialized, the second just after render, the third just before dispose, and the last 184 | just after. 185 | 186 | Yielding a promise is supported for async operations. 187 | 188 | I :heart: future JS. 189 | 190 | ## Execution Order 191 | 192 | Assuming navigation from from => to, where "X/app" indicates app middleware for route X, 193 | middleware is executed in the following order... 194 | 195 | - from: before dispose 196 | - from/app: before dispose 197 | 198 | - to/app: before render 199 | - to: before render 200 | 201 | - to: render 202 | 203 | - from: after dispose 204 | - from/app: after dispose 205 | 206 | - to/app: after render 207 | - to: after render 208 | 209 | *Why is the next page's before render middleware called before this one is disposed 210 | entirely!?* 211 | 212 | Good question. This gives the best possible UX by preventing intermediate whitespace 213 | while asynchronous beforeRender middleware is executing. See the [loading-animation](../examples/loading-animation) example for more. 214 | 215 | ## Using with Nested Routing 216 | 217 | When used with nested routing, child middleware will also be executed as part of the 218 | execution chain, meaning as long as data is gathered in middleware, it will all be 219 | available down the chain for a deep synchronous render. 220 | 221 | --- 222 | 223 | [Back](./) 224 | -------------------------------------------------------------------------------- /docs/nested.md: -------------------------------------------------------------------------------- 1 | # Nested 2 | 3 | Nested routes are registered by passing a route object as the config for another route 4 | object. Easy enough. 5 | 6 | ```javascript 7 | import ko from 'knockout' 8 | import { Router } from 'ko-component-router' 9 | 10 | Router.useRoutes({ 11 | '/user': { 12 | '/': 'user-list', 13 | '/new': 'user-create', 14 | '/:id': 'user-show', 15 | '/:id/edit': 'user-edit' 16 | } 17 | }) 18 | 19 | ko.components.register('user-list', ...) 20 | ko.components.register('user-create', ...) 21 | ko.components.register('user-show', ...) 22 | ko.components.register('user-edit', ...) 23 | ``` 24 | 25 | ## Custom Wrapper Components 26 | 27 | If an empty `ko-component-router` isn't enough for you, you can still pass a component 28 | name to the route along with your nested routes, and include a `` 29 | in that component. 30 | 31 | ```javascript 32 | import ko from 'knockout' 33 | import { Router } from 'ko-component-router' 34 | 35 | Router.useRoutes({ 36 | '/user': [ 37 | 'user-header', 38 | { 39 | '/': 'user-list', 40 | '/new': 'user-create', 41 | '/:id': 'user-show', 42 | '/:id/edit': 'user-edit' 43 | } 44 | ] 45 | }) 46 | 47 | ko.components.register('user-header', { 48 | template: ` 49 | 50 | List 51 | 52 | 53 | New User 54 | 55 | 56 | 57 | ` 58 | }) 59 | 60 | ko.components.register('user-list', ...) 61 | ko.components.register('user-create', ...) 62 | ko.components.register('user-show', ...) 63 | ko.components.register('user-edit', ...) 64 | ``` 65 | 66 | ## Passing Data to Children 67 | 68 | One short-lived feature of the router was passthrough params; for reasons outlined 69 | in [this blog post](https://medium.com/@notCaseyWebb/building-a-better-router-ef42896e2e5a), 70 | that presented issues with optimal execution of nested router middleware. In order 71 | to pass data between parent and child routers, it must be attached to the context in 72 | a middleware function. This is an opinionated approach that prevents you from doing 73 | initialization/data fetching in the viewModels, but this should be seen as a 74 | best practice and not a downfall as it leads to more modularization, 75 | easier unit testing of business logic, and easier to maintain viewModels as they 76 | are concerned only with UI interactions. See [best practices](./best-practices.md) for more. 77 | 78 | --- 79 | 80 | [Back](./) 81 | -------------------------------------------------------------------------------- /docs/path-binding.md: -------------------------------------------------------------------------------- 1 | # Path Binding 2 | 3 | For your convenience, the router includes a binding called `path` that allows 4 | you to set the href of anchor tags without mucking around with the base path 5 | and having to juggle different levels too much. 6 | 7 | **NOTE:** The path binding sets the href attribute, so it will only work on anchor 8 | tags for navigation. However, it can be used on any element for styling. 9 | 10 | Parses path using [utils.traversePath](./utils.md#traversePath) 11 | 12 | ### Local 13 | ```html 14 | 15 | ``` 16 | 17 | This will route to the `/foo` route on the current router (the one that this 18 | page belongs to). 19 | 20 | ### Absolute 21 | ```html 22 | 23 | ``` 24 | 25 | This will route to the `/foo` route at the top-level router. 26 | 27 | ### Relative 28 | __parent__ 29 | ```html 30 | 31 | ``` 32 | 33 | This will route to the `/foo` route at the parent router. 34 | 35 | __child__ 36 | ```html 37 | 38 | ``` 39 | 40 | This will route to the `/foo` route at the child (adjacent) router. 41 | 42 | 43 | ## Styling Elements 44 | By default, the router adds the `active-path` css class to any anchor with a 45 | path binding that resolves to the current page. To use a class other than 46 | `active-path`, you may configure it globally in the router's config as 47 | `activePathCSSClass`, or use the supplementary `pathActiveClass` binding. 48 | 49 | ```html 50 | 51 | ``` 52 | 53 | --- 54 | 55 | [Back](./) 56 | -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | A primary goal of this router is to be flexible enough to allow you to structure 4 | your app as you wish. The primary method used to attain this is via plugins. 5 | 6 | Plugins are functions that take your route value and return middleware — if applicable — 7 | allowing you to format your routes in any way you see fit. 8 | 9 | For example say you wanted to refactor this... 10 | 11 | ```javascript 12 | import ko from 'knockout' 13 | import { Router } from 'ko-component-router' 14 | import loadData from './utils/loadData' 15 | 16 | ko.components.register('foo', { 17 | template: '

FOO!

' 18 | }) 19 | 20 | Router.routes = { 21 | '/foo': [ 22 | (ctx) => loadData(ctx).then((data) => ctx.data = data), 23 | (ctx) => document.title = ctx.data.title, 24 | 'foo', 25 | { 26 | { 27 | '/bar': 'bar' 28 | } 29 | } 30 | ] 31 | } 32 | ``` 33 | 34 | ...into a much more readable... 35 | 36 | ```javascript 37 | import { Router } from 'ko-component-router' 38 | import loadData from './utils/loadData' 39 | 40 | Router.routes = { 41 | '/foo': { 42 | component: { 43 | template: '

FOO!

' 44 | }, 45 | loadData, 46 | setTitle(ctx) { 47 | document.title = ctx.data.title 48 | }, 49 | routes: { 50 | '/bar': 'bar' 51 | } 52 | } 53 | } 54 | ``` 55 | 56 | We could do this by registering a plugin that essentially takes the latter, and 57 | returns the former. 58 | 59 | ```javascript 60 | import { Router } from 'ko-component-router' 61 | 62 | Router.usePlugin((route) => { 63 | return [ 64 | (ctx) => route.loadData(ctx).then((data) => ctx.data = data), 65 | (ctx) => route.setTitle(ctx), 66 | route.routes, 67 | (ctx) => ({ 68 | beforeRender() { 69 | ko.components.register(ctx.pathname, route.component) 70 | ctx.route.component = ctx.pathname 71 | }, 72 | afterDispose() { ko.components.unregister(ctx.pathname) } 73 | }) 74 | ] 75 | }) 76 | ``` 77 | 78 | Better yet, plugins can be composed as such... 79 | 80 | ```javascript 81 | import { Router } from 'ko-component-router' 82 | 83 | function componentPlugin(route) { 84 | return (ctx) => ({ 85 | beforeRender() { 86 | ko.components.register(ctx.pathname, route.component) 87 | ctx.route.component = ctx.pathname 88 | }, 89 | afterDispose() { 90 | ko.components.unregister(ctx.pathname) 91 | } 92 | }) 93 | } 94 | 95 | function titlePlugin(route) { 96 | return (ctx) => route.setTitle(ctx) 97 | } 98 | 99 | function nestedRoutePlugin(route) { 100 | return route.routes 101 | } 102 | 103 | function loadDataPlugin(route) { 104 | return (ctx) => route.loadData(ctx).then((data) => ctx.data = data) 105 | } 106 | 107 | Router.plugins = [ 108 | componentPlugin, 109 | titlePlugin, 110 | nestedRoutePlugin, 111 | loadDataPlugin 112 | ] 113 | ``` 114 | 115 | As you've seen, plugins are registered by using `Routes.usePlugin(fn)`. 116 | 117 | Plugins must be registered *before* routes. 118 | 119 | Plugins may return anything the router can make sense of, i.e. a middleware function, 120 | a component name, or a nested route object. They can also return an array of any combination 121 | of the preceding, as shown in the example without composition. If a plugin doesn't 122 | return anything, or returns false, it will be treated as an empty array. 123 | 124 | If the array accumulated by running all plugins against a given route is empty, it 125 | is assumed the route is in a format the router understands, and it will be parsed. 126 | Otherwise — so if a plugin was "successful" at interpreting the route and returned something — 127 | the route will not be passed along. 128 | 129 | Routes with array configs will have the plugins ran against each config in that array, not the whole array. 130 | 131 | --- 132 | 133 | [Back](./) 134 | -------------------------------------------------------------------------------- /docs/router.md: -------------------------------------------------------------------------------- 1 | # Router 2 | 3 | *ES2015* 4 | ```javascript 5 | import { Router } from 'ko-component-router' 6 | ``` 7 | 8 | *CommonJS* 9 | ```javascript 10 | const { Router } = require('ko-component-router') 11 | ``` 12 | 13 | *Browser Globals* 14 | ```javascript 15 | const { Router } = ko.router 16 | ``` 17 | 18 | ## API 19 | 20 | ### Instance 21 | 22 | #### router.ctx 23 | Current [router context](./context.md) object 24 | 25 | #### router.initialized 26 | Promise that returns after this router is initialized 27 | 28 | #### router.isNavigating() 29 | Observable value that is true if router is navigating 30 | 31 | #### router.isRoot 32 | Is the root router 33 | 34 | #### router.update(path, [push = true], [options = { push: true, force: false, with: {} }]) 35 | Routes to `path`; adds history state entry if `push === true` 36 | 37 | Second argument can be a boolean `push`, or an options object: 38 | 39 | | Option | Description | Default | 40 | | ------ | ------------------------------ | ------- | 41 | | push | push history state entry | true | 42 | | force | force reload of same route | false | 43 | | with | object to extend context with | {} | 44 | 45 | #### router.$parent 46 | Parent router accessor 47 | 48 | #### router.$parents 49 | Array of parent routers 50 | 51 | ### Static 52 | 53 | #### Router.get(index) 54 | Return router at the given depth, beginning at 0 55 | 56 | #### Router.head 57 | Top-most router 58 | 59 | #### Router.initialized 60 | Alias for `Router.head.initialized` 61 | 62 | #### Router.setConfig({ base = '', hashbang = false, activePathCSSClass = 'active-path' }) 63 | Sets router configuration 64 | 65 | #### Router.use(...fns) 66 | Registers app [middleware](./middleware.md) 67 | 68 | #### Router.usePlugin(...fns) 69 | Registers [plugin](./plugins.md) 70 | 71 | Plugins must be registered *before* routes 72 | 73 | #### Router.useRoutes(routes) 74 | Registers routes 75 | 76 | #### Router.update(path, [push = true], [options = { push: true, force: false, with: {} }]) 77 | Convenience function for `Router.get(0).update(...)` 78 | 79 | --- 80 | 81 | [Back](./README.md) 82 | -------------------------------------------------------------------------------- /docs/typescript.md: -------------------------------------------------------------------------------- 1 | # TypeScript Support 2 | 3 | If you're using TypeScript — *as you most definitely should be* — you're in luck! The router is written with first-class support for TypeScript. 4 | 5 | That being said, it isn't immediately obvious how to take advantage of type-safety if you use middleware and plugins that extend the context. 6 | 7 | Let's say you want to have some middleware that sets a `data` property on the context, like so... 8 | 9 | ```javascript 10 | import { Router } from 'ko-component-router' 11 | 12 | Router.use(async (ctx) => { 13 | ctx.data = await fetchSomeData() 14 | }) 15 | ``` 16 | 17 | Because `ctx` is strongly-typed, we get an error as expected... 18 | 19 | > Property 'data' does not exist on type 'Context & IContext' 20 | 21 | We could use the dirty `(ctx as any).data = ...` hack that anyone who has used TypeScript for more than a day has undoubtedly had to use, but it would be way better if we could let the compiler know about the new property so we get all the benefits type-safety brings to the table like autocompletion. To do this, it's as simple as adding a `declare` statement to our middleware file, like so... 22 | 23 | ```typescript 24 | import { Router } from 'ko-component-router' 25 | 26 | declare module 'ko-component-router' { 27 | interface IContext { 28 | data?: MyDataType 29 | } 30 | } 31 | 32 | Router.use(async (ctx) => { 33 | ctx.data = await fetchSomeData() 34 | }) 35 | ``` 36 | 37 | That's it! If `fetchSomeData()` returns something of the wrong type, the compiler will throw an error, and in your component viewModels, you will have full autocomplete of your custom properties. 38 | 39 | **NOTE:** It's an interface prefixed with `I`, *not* the normal `Context` class. This is because TypeScript does not support declaration merging on classes. 40 | 41 | You may also take advantage of some types that are exported, namely `RouteConfig`, `RouteMap`, `Middleware`, and `Plugin`. You can use these to specify types where the compiler cannot otherwise infer them. For example... 42 | 43 | ```typescript 44 | import { Plugin } from 'ko-component-router' 45 | 46 | declare module 'ko-component-router' { 47 | interface IContext { 48 | data: string 49 | } 50 | } 51 | 52 | const apiPlugin: Plugin = ({ apiUrl }) => async (ctx) => { 53 | ctx.data = await $.get(apiUrl) 54 | } 55 | 56 | export default apiPlugin 57 | ``` -------------------------------------------------------------------------------- /docs/utils.md: -------------------------------------------------------------------------------- 1 | # Utils 2 | 3 | ## API 4 | 5 | #### isActivePath({ router, path }) 6 | Returns `true` if `path` for `router` is currently active 7 | 8 | #### resolveHref({ router, path }) 9 | Gets an absolute path for `path` on `router` 10 | 11 | #### traversePath(router, path) 12 | Resolves `path` in relation to `router` 13 | 14 | ###### Local 15 | '/foo' route to the `/foo` route on `router` 16 | 17 | ###### Absolute 18 | '//foo' will route to the `/foo` route on `router.$root` 19 | 20 | ###### Relative 21 | __parent__ 22 | '../foo' will route to the `/foo` route on `router.$parent` 23 | 24 | __child__ 25 | './foo' will route to the `/foo` route on `router.$child` 26 | -------------------------------------------------------------------------------- /examples/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["syntax-dynamic-import"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "profiscience", 3 | "parserOptions": { 4 | "sourceType": "module" 5 | } 6 | } -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # ko-component-router examples 2 | 3 | *In this directory* 4 | 5 | ```bash 6 | $ npm start 7 | ``` 8 | 9 | Open http://localhost:8080/ in your browser 10 | -------------------------------------------------------------------------------- /examples/hashbang/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ko-component-router hashbang example 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/hashbang/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import { Router } from 'ko-component-router' 3 | 4 | function createTemplate(foo) { 5 | return ` 6 |
back 7 |

${foo}

8 | ` 9 | } 10 | 11 | ko.components.register('app', { 12 | template: ` 13 | 14 | ` 15 | }) 16 | 17 | ko.components.register('index', { 18 | template: ` 19 | 25 | ` 26 | }) 27 | 28 | ko.components.register('foo', { template: createTemplate('foo') }) 29 | ko.components.register('bar', { template: createTemplate('bar') }) 30 | ko.components.register('baz', { template: createTemplate('baz') }) 31 | ko.components.register('qux', { template: createTemplate('qux') }) 32 | 33 | Router.useRoutes({ 34 | '/': 'index', 35 | '/foo': 'foo', 36 | '/bar': 'bar', 37 | '/baz': 'baz', 38 | '/qux': 'qux' 39 | }) 40 | 41 | Router.setConfig({ 42 | base: '/hashbang', 43 | hashbang: true 44 | }) 45 | 46 | ko.applyBindings() 47 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ko-component-router examples 8 | 9 | 10 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/lazy-loading/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ko-component-router lazy-loading example 8 | 9 | 10 |

Check the network panel in the dev tools

11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/lazy-loading/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import { Router } from 'ko-component-router' 3 | import lazyComponentLoaderPlugin from './plugins/lazy-component-loader' 4 | 5 | Router.setConfig({ base: '/lazy-loading', hashbang: true }) 6 | 7 | Router.usePlugin(lazyComponentLoaderPlugin) 8 | 9 | Router.useRoutes({ 10 | '/': 'list', 11 | '/foo': 'foo', 12 | '/bar': 'bar', 13 | '/baz': 'baz', 14 | '/qux': 'qux' 15 | } 16 | ) 17 | 18 | ko.components.register('app', { template: '' }) 19 | 20 | ko.applyBindings() 21 | -------------------------------------------------------------------------------- /examples/lazy-loading/plugins/lazy-component-loader.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | export default function plugin(componentName) { 4 | return [ 5 | // we return an array that the router understands, so first we'll 6 | // include the name of the component 7 | componentName, 8 | 9 | // then some middleware to load that component... 10 | () => { 11 | // bail if already loaded 12 | if (ko.components.isRegistered(componentName)) { 13 | return 14 | } 15 | 16 | // https://webpack.js.org/guides/code-splitting-import/ 17 | return import('../views/' + componentName + '/index.js') 18 | .then((exports) => ko.components.register(componentName, exports)) 19 | // eslint-disable-next-line no-console 20 | .catch((err) => console.error('Error fetching component', componentName, err)) 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/bar/index.js: -------------------------------------------------------------------------------- 1 | export { default as template } from './template.html' 2 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/bar/template.html: -------------------------------------------------------------------------------- 1 |

bar

2 | 3 |
4 | 5 | back 6 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/baz/index.js: -------------------------------------------------------------------------------- 1 | export { default as template } from './template.html' 2 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/baz/template.html: -------------------------------------------------------------------------------- 1 |

baz

2 | 3 |
4 | 5 | back 6 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/foo/index.js: -------------------------------------------------------------------------------- 1 | export { default as template } from './template.html' 2 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/foo/template.html: -------------------------------------------------------------------------------- 1 |

foo

2 | 3 |
4 | 5 | back 6 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/list/index.js: -------------------------------------------------------------------------------- 1 | export { default as template } from './template.html' 2 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/list/template.html: -------------------------------------------------------------------------------- 1 | foo 2 |
3 | 4 | bar 5 |
6 | 7 | baz 8 |
9 | 10 | qux 11 |
12 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/qux/index.js: -------------------------------------------------------------------------------- 1 | export { default as template } from './template.html' 2 | -------------------------------------------------------------------------------- /examples/lazy-loading/views/qux/template.html: -------------------------------------------------------------------------------- 1 |

qux

2 | 3 |
4 | 5 | back 6 | -------------------------------------------------------------------------------- /examples/loading-animation/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ko-component-router lazy-loading example 8 | 9 | 76 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /examples/loading-animation/index.js: -------------------------------------------------------------------------------- 1 | import { random } from 'lodash-es' 2 | import ko from 'knockout' 3 | import { Router } from 'ko-component-router' 4 | import loadingMiddleware from './middleware/loading' 5 | 6 | import * as foo from './views/foo' 7 | import * as bar from './views/bar' 8 | 9 | const loading = ko.observable(true) 10 | 11 | Router.setConfig({ base: '/loading-animation', hashbang: true }) 12 | 13 | // pass loading observable into middleware 14 | Router.use(loadingMiddleware(loading)) 15 | 16 | Router.useRoutes({ 17 | '/': (ctx) => ctx.redirect('/foo'), 18 | 19 | // simulate a deeply nested route w/ timeouts (would be ajax or what have you 20 | // in real life) 21 | '/foo': [ 22 | randomTimeout, 23 | { 24 | '/': [ 25 | randomTimeout, 26 | { 27 | '/': [ 28 | randomTimeout, 29 | { 30 | '/': [ 31 | randomTimeout, 32 | { 33 | '/': [ 34 | randomTimeout, 35 | { 36 | '/': 'foo' 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | ] 43 | } 44 | ] 45 | } 46 | ], 47 | '/bar': 'bar' 48 | }) 49 | 50 | ko.components.register('app', { template: '' }) 51 | ko.components.register('foo', foo) 52 | ko.components.register('bar', bar) 53 | 54 | // loader is in html, so that it is visible on page load before/while scripts 55 | // are parsed and the app initializes 56 | ko.applyBindings({ loading }) 57 | 58 | function randomTimeout() { 59 | return new Promise((resolve) => setTimeout(resolve, random(1000))) 60 | } 61 | -------------------------------------------------------------------------------- /examples/loading-animation/middleware/loading.js: -------------------------------------------------------------------------------- 1 | import ToProgress from 'toprogress' 2 | 3 | let loadingBar, loadingBarInterval 4 | 5 | export default (loading) => function * (ctx) { 6 | // run once for top-most router 7 | if (!loadingBar) { 8 | // toggle overlay (observable passed in) 9 | loading(true) 10 | 11 | // start loading bar 12 | loadingBar = new ToProgress({ 13 | color: '#000', 14 | duration: 0.2, 15 | height: '5px' 16 | }) 17 | loadingBarInterval = setInterval(() => { 18 | loadingBar.increase(1) 19 | }, 100) 20 | } 21 | 22 | yield 23 | 24 | // end loading in bottom-most router afterRender 25 | if (!ctx.$child) { 26 | loadingBar.finish() 27 | clearInterval(loadingBarInterval) 28 | loading(false) 29 | // reset for next navigation 30 | loadingBar = null 31 | loadingBarInterval = null 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/loading-animation/views/bar/index.js: -------------------------------------------------------------------------------- 1 | export { default as template } from './template.html' 2 | -------------------------------------------------------------------------------- /examples/loading-animation/views/bar/template.html: -------------------------------------------------------------------------------- 1 |

bar

2 | 3 |
4 | 5 | go to foo 6 | -------------------------------------------------------------------------------- /examples/loading-animation/views/foo/index.js: -------------------------------------------------------------------------------- 1 | export { default as template } from './template.html' 2 | -------------------------------------------------------------------------------- /examples/loading-animation/views/foo/template.html: -------------------------------------------------------------------------------- 1 |

foo

2 | 3 |
4 | 5 | go to bar 6 | -------------------------------------------------------------------------------- /examples/mvc/components/user-card/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import template from './template.html' 3 | import viewModel from './viewmodel' 4 | 5 | ko.components.register('user-card', { template, viewModel, synchronous: true }) 6 | -------------------------------------------------------------------------------- /examples/mvc/components/user-card/template.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | 5 |

6 | 7 | 8 |   9 | 10 | 11 |
12 | 13 | 14 |   15 | 16 | 17 |
18 |
19 | 20 | 21 | Edit 22 | 23 | 24 | Delete 25 | 26 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /examples/mvc/components/user-card/viewmodel.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'ko-component-router' 2 | 3 | export default class UserCardComponentViewModel { 4 | constructor({ user }) { 5 | this.user = user 6 | } 7 | 8 | destroy() { 9 | this.user.destroy() 10 | Router.update('/', { force: true }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/mvc/components/user-editor/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import template from './template.html' 3 | import viewModel from './viewmodel' 4 | 5 | ko.components.register('user-editor', { template, viewModel, synchronous: true }) 6 | -------------------------------------------------------------------------------- /examples/mvc/components/user-editor/template.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 |
7 |
8 | 9 |
10 | 11 |
12 |
13 |
14 | 15 |
16 | 17 |
18 |
19 |
20 | 21 | Back 22 | 23 |   24 | 27 |
28 | -------------------------------------------------------------------------------- /examples/mvc/components/user-editor/viewmodel.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'ko-component-router' 2 | 3 | export default class UserEditorComponentViewModel { 4 | constructor({ user }) { 5 | this.user = user 6 | } 7 | 8 | save() { 9 | this.user.save() 10 | Router.update(`/${this.user.id}`) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/mvc/controllers/edit.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user' 2 | 3 | export * from '../views/edit' 4 | 5 | export function beforeRender(ctx) { 6 | ctx.user = User.fetch(ctx.params.id) 7 | } 8 | -------------------------------------------------------------------------------- /examples/mvc/controllers/list.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user' 2 | 3 | export * from '../views/list' 4 | 5 | export function beforeRender(ctx) { 6 | ctx.users = User.fetchAll() 7 | } 8 | -------------------------------------------------------------------------------- /examples/mvc/controllers/new.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user' 2 | 3 | export * from '../views/new' 4 | 5 | export function beforeRender(ctx) { 6 | ctx.user = new User() 7 | } 8 | -------------------------------------------------------------------------------- /examples/mvc/controllers/show.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user' 2 | 3 | export * from '../views/show' 4 | 5 | export function beforeRender(ctx) { 6 | ctx.user = User.fetch(ctx.params.id) 7 | } 8 | -------------------------------------------------------------------------------- /examples/mvc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ko-component-router mvc example 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/mvc/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import { Router } from 'ko-component-router' 3 | import routes from './routes' 4 | import componentPlugin from './plugins/component' 5 | import middlewarePlugin from './plugins/middleware' 6 | 7 | Router.setConfig({ base: '/mvc', hashbang: true }) 8 | 9 | Router.usePlugin(componentPlugin) 10 | Router.usePlugin(middlewarePlugin) 11 | 12 | Router.useRoutes(routes) 13 | 14 | ko.components.register('app', { template: '' }) 15 | 16 | ko.applyBindings() 17 | -------------------------------------------------------------------------------- /examples/mvc/models/user.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import idGenerator from '../utils/id-generator' 3 | 4 | const ids = idGenerator() 5 | 6 | export default class User { 7 | constructor({ 8 | id = ids.next().value, 9 | name, 10 | email, 11 | phoneNumber 12 | } = {}) { 13 | this.id = id 14 | this.name = ko.observable(name) 15 | this.email = ko.observable(email) 16 | this.phoneNumber = ko.observable(phoneNumber) 17 | } 18 | 19 | save() { 20 | localStorage.setItem(`users.${this.id}`, ko.toJSON(this)) 21 | } 22 | 23 | destroy() { 24 | localStorage.removeItem(`users.${this.id}`) 25 | } 26 | 27 | static fetch(id) { 28 | return new User(JSON.parse(localStorage.getItem(`users.${id}`))) 29 | } 30 | 31 | static fetchAll() { 32 | return Object 33 | .keys(localStorage) 34 | .filter((k) => k.indexOf('users.') === 0) 35 | .map((k) => new User(JSON.parse(localStorage.getItem(k)))) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/mvc/plugins/component.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | export default function plugin({ template, viewModel }) { 4 | if (template) { 5 | let componentName 6 | return (ctx) => ({ 7 | beforeRender() { 8 | componentName = ctx.canonicalPath 9 | ctx.route.component = componentName 10 | ko.components.register(componentName, { template, viewModel, synchronous: true }) 11 | }, 12 | afterRender() { 13 | ko.components.unregister(componentName) 14 | } 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/mvc/plugins/middleware.js: -------------------------------------------------------------------------------- 1 | export default function plugin({ 2 | beforeRender = noop, 3 | afterRender = noop, 4 | beforeDispose = noop, 5 | afterDispose = noop 6 | }) { 7 | return (ctx) => ({ 8 | beforeRender: beforeRender.bind(null, ctx), 9 | afterRender: afterRender.bind(null, ctx), 10 | beforeDispose: beforeDispose.bind(null, ctx), 11 | afterDispose: afterDispose.bind(null, ctx) 12 | }) 13 | } 14 | 15 | function noop() { /* do nothing */ } 16 | -------------------------------------------------------------------------------- /examples/mvc/routes.js: -------------------------------------------------------------------------------- 1 | import * as list from './controllers/list' 2 | import * as _new from './controllers/new' 3 | import * as show from './controllers/show' 4 | import * as edit from './controllers/edit' 5 | 6 | export default { 7 | '/': list, 8 | '/new': _new, 9 | '/:id': show, 10 | '/:id/edit': edit 11 | } 12 | -------------------------------------------------------------------------------- /examples/mvc/utils/id-generator.js: -------------------------------------------------------------------------------- 1 | export default function * idGenerator() { 2 | const users = Object 3 | .keys(localStorage) 4 | .filter((k) => k.indexOf('users.') === 0) 5 | .map((k) => parseInt(k.replace('users.', ''))) 6 | 7 | let i = users[users.length - 1] + 1 || 0 8 | 9 | while (true) { 10 | yield i++ 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/mvc/views/edit/index.js: -------------------------------------------------------------------------------- 1 | import '../../components/user-editor' 2 | 3 | export { default as template } from './template.html' 4 | export { default as viewModel } from './viewmodel' 5 | -------------------------------------------------------------------------------- /examples/mvc/views/edit/template.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/mvc/views/edit/viewmodel.js: -------------------------------------------------------------------------------- 1 | export default class UserEditViewModel { 2 | constructor(ctx) { 3 | this.user = ctx.user 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/mvc/views/list/index.js: -------------------------------------------------------------------------------- 1 | import '../../components/user-card' 2 | 3 | export { default as template } from './template.html' 4 | export { default as viewModel } from './viewmodel' 5 | -------------------------------------------------------------------------------- /examples/mvc/views/list/template.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 | Add Contact 8 | -------------------------------------------------------------------------------- /examples/mvc/views/list/viewmodel.js: -------------------------------------------------------------------------------- 1 | export default class UserListViewModel { 2 | constructor(ctx) { 3 | this.users = ctx.users 4 | } 5 | 6 | destroy(user) { 7 | user.destroy() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/mvc/views/new/index.js: -------------------------------------------------------------------------------- 1 | import '../../components/user-editor' 2 | 3 | export { default as template } from './template.html' 4 | export { default as viewModel } from './viewmodel' 5 | -------------------------------------------------------------------------------- /examples/mvc/views/new/template.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/mvc/views/new/viewmodel.js: -------------------------------------------------------------------------------- 1 | export default class UserNewViewModel { 2 | constructor(ctx) { 3 | this.user = ctx.user 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/mvc/views/show/index.js: -------------------------------------------------------------------------------- 1 | import '../../components/user-card' 2 | 3 | export { default as template } from './template.html' 4 | export { default as viewModel } from './viewmodel' 5 | -------------------------------------------------------------------------------- /examples/mvc/views/show/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | Back 6 | -------------------------------------------------------------------------------- /examples/mvc/views/show/viewmodel.js: -------------------------------------------------------------------------------- 1 | export default class UserShowViewModel { 2 | constructor(ctx) { 3 | this.user = ctx.user 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "preinstall": "cd .. && (yarn install --ignore-scripts || npm install --ignore-scripts) && cd examples", 4 | "prestart": "yarn install || npm install", 5 | "start": "webpack-dev-server --hot" 6 | }, 7 | "dependencies": { 8 | "awesome-typescript-loader": "^3.1.3", 9 | "babel-core": "^6.24.1", 10 | "babel-loader": "^7.0.0", 11 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 12 | "babel-preset-es2017": "^6.24.1", 13 | "html-loader": "^0.4.5", 14 | "toprogress": "^0.1.3", 15 | "webpack": "^2.6.0", 16 | "webpack-dev-server": "^2.4.5" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/path-binding/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ko-component-router path-binding example 8 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/path-binding/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import { Router } from 'ko-component-router' 3 | 4 | function createOuterTemplate(foo) { 5 | return ` 6 |

${foo}

7 | 8 | These begin with '/', so they route using the current (containing) router 9 |
10 | /foo 11 | /bar 12 | 13 |
14 |
15 | 16 | This begins with './', so it is routed using the child (adjacent) router 17 |
18 | ./baz 19 | 20 |
21 |
22 | 23 | This begins with '//', so it is routed using the root router 24 |
25 | //${foo}/qux 26 | 27 | 28 | ` 29 | } 30 | 31 | function createInnerTemplate(foo) { 32 | return ` 33 |

${foo}

34 | 35 | These begin with '/', so they route using the current (containing) router 36 |
37 | /baz 38 | /qux 39 | ` 40 | } 41 | 42 | ko.components.register('app', { 43 | template: ` 44 | These paths exist outside any router, so '/' is good 45 |
46 | /foo 47 | /bar 48 | 49 | 50 | ` 51 | }) 52 | 53 | ko.components.register('empty', { template: '' }) 54 | 55 | ko.components.register('foo', { template: createOuterTemplate('foo') }) 56 | ko.components.register('bar', { template: createOuterTemplate('bar') }) 57 | ko.components.register('baz', { template: createInnerTemplate('baz') }) 58 | ko.components.register('qux', { template: createInnerTemplate('qux') }) 59 | 60 | Router.useRoutes({ 61 | '/': 'empty', 62 | '/foo': ['foo', 63 | { 64 | '/': 'empty', 65 | '/baz': 'baz', 66 | '/qux': 'qux' 67 | } 68 | ], 69 | '/bar': ['bar', 70 | { 71 | '/': 'empty', 72 | '/baz': 'baz', 73 | '/qux': 'qux' 74 | } 75 | ] 76 | }) 77 | 78 | Router.setConfig({ 79 | base: '/path-binding' 80 | }) 81 | 82 | ko.applyBindings() 83 | -------------------------------------------------------------------------------- /examples/simple-auth/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ko-component-router hashbang example 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/simple-auth/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import { Router } from 'ko-component-router' 3 | 4 | Router.setConfig({ 5 | base: '/simple-auth', 6 | hashbang: true 7 | }) 8 | 9 | // globally registered auth middleware, runs for every route 10 | Router.use((ctx) => { 11 | const isLoginPage = ctx.path === '/login' 12 | const isLoggedIn = sessionStorage.getItem('authenticated') 13 | 14 | if (!isLoggedIn && !isLoginPage) { 15 | ctx.redirect('//login') 16 | } else if (isLoggedIn && isLoginPage) { 17 | ctx.redirect('//') 18 | } 19 | }) 20 | 21 | Router.useRoutes({ 22 | '/': 'home', 23 | '/login': 'login', 24 | '/logout': (ctx) => { 25 | sessionStorage.removeItem('authenticated') 26 | ctx.redirect('/login') 27 | } 28 | }) 29 | 30 | ko.components.register('home', { 31 | template: ` 32 | Logout 33 | ` 34 | }) 35 | 36 | ko.components.register('login', { 37 | viewModel: class { 38 | login() { 39 | sessionStorage.setItem('authenticated', true) 40 | Router.update('/') 41 | } 42 | }, 43 | template: ` 44 |

Login

45 | 46 | ` 47 | }) 48 | 49 | ko.applyBindings() 50 | -------------------------------------------------------------------------------- /examples/webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' // eslint-disable-line strict 2 | 3 | const path = require('path') 4 | 5 | module.exports = { 6 | entry: { 7 | 'hashbang': path.resolve(__dirname, './hashbang/index.js'), 8 | 'lazy-loading': path.resolve(__dirname, './lazy-loading/index.js'), 9 | 'loading-animation': path.resolve(__dirname, './loading-animation/index.js'), 10 | 'mvc': path.resolve(__dirname, './mvc/index.js'), 11 | 'path-binding': path.resolve(__dirname, './path-binding/index.js'), 12 | 'simple-auth': path.resolve(__dirname, './simple-auth/index.js') 13 | }, 14 | output: { 15 | publicPath: '/dist/', 16 | filename: '[name].js' 17 | }, 18 | devServer: { 19 | contentBase: __dirname 20 | }, 21 | devtool: 'inline-source-map', 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts$/, 26 | loader: 'awesome-typescript-loader', 27 | exclude: [ 28 | path.resolve('node_modules') 29 | ], 30 | options: { 31 | configFileName: path.resolve(__dirname, '../tsconfig.json'), 32 | useBabel: true, 33 | useCache: true, 34 | cacheDirectory: path.resolve(__dirname, '.cache'), 35 | module: 'es2015' 36 | } 37 | }, 38 | { 39 | test: /\.js$/, 40 | loader: 'babel-loader', 41 | exclude: [ 42 | path.resolve('node_modules') 43 | ], 44 | options: { 45 | cacheDirectory: true 46 | } 47 | }, 48 | { 49 | test: /\.html$/, 50 | loader: 'html-loader' 51 | } 52 | ] 53 | }, 54 | resolve: { 55 | alias: { 56 | 'ko-component-router': path.resolve(__dirname, '../src') 57 | }, 58 | extensions: [ 59 | '.js', 60 | '.ts' 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ko-component-router.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("knockout")):"function"==typeof define&&define.amd?define(["exports","knockout"],e):e((t.ko=t.ko||{},t.ko.router={}),t.ko)}(this,function(t,e){"use strict";function r(t,e,r,n){return new(r||(r=Promise))(function(o,i){function u(t){try{c(n.next(t))}catch(t){i(t)}}function a(t){try{c(n.throw(t))}catch(t){i(t)}}function c(t){t.done?o(t.value):new r(function(e){e(t.value)}).then(u,a)}c((n=n.apply(t,e||[])).next())})}function n(t,e){function r(t){return function(e){return n([t,e])}}function n(r){if(o)throw new TypeError("Generator is already executing.");for(;c;)try{if(o=1,i&&(u=i[2&r[0]?"return":r[0]?"throw":"next"])&&!(u=u.call(i,r[1])).done)return u;switch(i=0,u&&(r=[0,u.value]),r[0]){case 0:case 1:u=r;break;case 4:return c.label++,{value:r[1],done:!1};case 5:c.label++,i=r[1],r=[0];continue;case 7:r=c.ops.pop(),c.trys.pop();continue;default:if(u=c.trys,!(u=u.length>0&&u[u.length-1])&&(6===r[0]||2===r[0])){c=0;continue}if(3===r[0]&&(!u||r[1]>u[0]&&r[1]=t.length&&(t=void 0),{value:t&&t[r++],done:!t}}}}function i(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),u=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)u.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return u}function u(){for(var t=[],e=0;e1||o(t,e)})})}function o(t,e){try{i(l[t](e))}catch(t){s(h[0][3],t)}}function i(t){t.value instanceof a?Promise.resolve(t.value.v).then(u,c):s(h[0][2],t)}function u(t){o("next",t)}function c(t){o("throw",t)}function s(t,e){t(e),h.shift(),h.length&&o(h[0][0],h[0][1])}if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var f,l=r.apply(t,e||[]),h=[];return f={},n("next"),n("throw"),n("return"),f[Symbol.asyncIterator]=function(){return this},f}function s(t,e){return e={exports:{}},t(e,e.exports),e.exports}function f(t){var e=Pr.call(t,Ar),r=t[Ar];try{t[Ar]=void 0;var n=!0}catch(t){}var o=$r.call(t);return n&&(e?t[Ar]=r:delete t[Ar]),o}function l(t){return Sr.call(t)}function h(t){return null==t?void 0===t?Er:Rr:zr&&zr in Object(t)?f(t):l(t)}function p(t){var e=typeof t;return null!=t&&("object"==e||"function"==e)}function d(t){if(!p(t))return!1;var e=h(t);return e==Mr||e==Dr||e==Cr||e==Tr}function v(t){return void 0===t}function b(t,e){return function(r){return t(e(r))}}function y(t){return null!=t&&"object"==typeof t}function g(t){if(!y(t)||h(t)!=Nr)return!1;var e=Br(t);if(null===e)return!0;var r=Ur.call(e,"constructor")&&e.constructor;return"function"==typeof r&&r instanceof r&&Lr.call(r)==qr}function _(){}function m(t,e,r){return t===t&&(void 0!==r&&(t=t<=r?t:r),void 0!==e&&(t=t>=e?t:e)),t}function w(t,e){for(var r=-1,n=null==t?0:t.length,o=Array(n);++r0&&r(a)?e>1?D(a,e-1,r,n,o):z(o,a):n||(o[o.length]=a)}return o}function T(t,e){var r=-1,n=t.length;for(e||(e=Array(n));++r-1&&t%1==0&&t<=zn}function Z(t){return null!=t&&Y(t.length)&&!d(t)}function tt(t,e){return!!(e=null==e?Cn:e)&&("number"==typeof t||Mn.test(t))&&t>-1&&t%1==0&&t1?r[o-1]:void 0,u=o>2?r[2]:void 0;for(i=t.length>3&&"function"==typeof i?(o--,i):void 0,u&&et(r[0],r[1],u)&&(i=o<3?void 0:i,o=1),e=Object(e);++n-1}function mt(t,e){var r=this.__data__,n=bt(r,t);return n<0?(++this.size,r.push([t,e])):r[n][1]=e,this}function wt(t){var e=-1,r=null==t?0:t.length;for(this.clear();++ea))return!1;var s=i.get(t);if(s&&i.get(e))return s==e;var f=-1,l=!0,h=r&wo?new Qt:void 0;for(i.set(t,e),i.set(e,t);++f-1&&(p=u[_],u=u.slice(0,_))}u&&(n.push(u),u="",s=!1);var m=""!==p&&void 0!==d&&d!==p,w="+"===g||"*"===g,j="?"===g||"*"===g,x=p||a,O=b||y;n.push({name:v||o++,prefix:p,delimiter:x,optional:j,repeat:w,partial:m,pattern:O?Ue(O):"[^"+Le(x)+"]+?"})}}return(u||i-1;else{var h=Le(l.prefix),p=l.repeat?"(?:"+l.pattern+")(?:"+h+"(?:"+l.pattern+"))*":l.pattern;e&&e.push(l),l.optional?l.partial?c+=h+"("+p+")?":c+="(?:"+h+"("+p+"))?":c+=h+"("+p+")"}}return o?(n||(c+="(?:"+i+")?"),c+="$"===a?"$":"(?="+a+")"):(n||(c+="(?:"+i+"(?="+a+"))?"),s||(c+="(?="+i+"|"+a+")")),new RegExp("^"+c,qe(r))}function Ge(t,e,r){return t instanceof RegExp?Qe(t,e):Array.isArray(t)?Ve(t,e,r):We(t,e,r)}function Ke(t){var e=Ei.head;if(e)for(;e.bound;)e=e.ctx.$child.router;else e=new Ei(Ei.getPathFromLocation(),void 0,t);if(e.bound=!0,e.isRoot)e.ctx.runBeforeRender().then(function(){e.ctx._redirect?e.ctx.runAfterRender().then(function(){var t=Re(e,e.ctx._redirect),r=t.router,n=t.path;r.update(n,e.ctx._redirectArgs)}):(e.ctx.render(),xe(Ei.onInit,function(t){return t(e)}))});else if(e.ctx._redirect){var r=Re(e,e.ctx._redirect),n=r.router,o=r.path;setTimeout(function(){return n.update(o,e.ctx._redirectArgs)})}return e}var Je=s(function(t){var e=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=e)}),Xe=s(function(t){var e=t.exports={version:"2.5.1"};"number"==typeof __e&&(__e=e)}),Ye=Je["__core-js_shared__"]||(Je["__core-js_shared__"]={}),Ze=function(t){return Ye[t]||(Ye[t]={})},tr=0,er=Math.random(),rr=function(t){return"Symbol(".concat(void 0===t?"":t,")_",(++tr+er).toString(36))},nr=s(function(t){var e=Ze("wks"),r=Je.Symbol,n="function"==typeof r;(t.exports=function(t){return e[t]||(e[t]=n&&r[t]||(n?r:rr)("Symbol."+t))}).store=e}),or=nr,ir={f:or},ur=function(t){return"object"==typeof t?null!==t:"function"==typeof t},ar=function(t){if(!ur(t))throw TypeError(t+" is not an object!");return t},cr=function(t){try{return!!t()}catch(t){return!0}},sr=!cr(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a}),fr=Je.document,lr=ur(fr)&&ur(fr.createElement),hr=function(t){return lr?fr.createElement(t):{}},pr=!sr&&!cr(function(){return 7!=Object.defineProperty(hr("div"),"a",{get:function(){return 7}}).a}),dr=function(t,e){if(!ur(t))return t;var r,n;if(e&&"function"==typeof(r=t.toString)&&!ur(n=r.call(t)))return n;if("function"==typeof(r=t.valueOf)&&!ur(n=r.call(t)))return n;if(!e&&"function"==typeof(r=t.toString)&&!ur(n=r.call(t)))return n;throw TypeError("Can't convert object to primitive value")},vr=Object.defineProperty,br=sr?Object.defineProperty:function(t,e,r){if(ar(t),e=dr(e,!0),ar(r),pr)try{return vr(t,e,r)}catch(t){}if("get"in r||"set"in r)throw TypeError("Accessors not supported!");return"value"in r&&(t[e]=r.value),t},yr={f:br},gr=yr.f,_r=function(t){var e=Xe.Symbol||(Xe.Symbol=Je.Symbol||{});"_"==t.charAt(0)||t in e||gr(e,t,{value:ir.f(t)})};_r("asyncIterator"),_r("observable");var mr="object"==typeof global&&global&&global.Object===Object&&global,wr="object"==typeof self&&self&&self.Object===Object&&self,jr=mr||wr||Function("return this")(),xr=jr.Symbol,Or=Object.prototype,Pr=Or.hasOwnProperty,$r=Or.toString,Ar=xr?xr.toStringTag:void 0,kr=Object.prototype,Sr=kr.toString,Rr="[object Null]",Er="[object Undefined]",zr=xr?xr.toStringTag:void 0,Cr="[object AsyncFunction]",Mr="[object Function]",Dr="[object GeneratorFunction]",Tr="[object Proxy]",Br=b(Object.getPrototypeOf,Object),Nr="[object Object]",Ir=Function.prototype,Fr=Object.prototype,Lr=Ir.toString,Ur=Fr.hasOwnProperty,qr=Lr.call(Object),Qr=Array.isArray,Vr="[object Symbol]",Wr=1/0,Hr=xr?xr.prototype:void 0,Gr=Hr?Hr.toString:void 0,Kr=NaN,Jr=/^\s+|\s+$/g,Xr=/^[-+]0x[0-9a-f]+$/i,Yr=/^0b[01]+$/i,Zr=/^0o[0-7]+$/i,tn=parseInt,en=1/0,rn=1.7976931348623157e308,nn="[object Boolean]",on="[object String]",un="[object Arguments]",an=Object.prototype,cn=an.hasOwnProperty,sn=an.propertyIsEnumerable,fn=C(function(){return arguments}())?C:function(t){return y(t)&&cn.call(t,"callee")&&!sn.call(t,"callee")},ln=xr?xr.isConcatSpreadable:void 0,hn=jr["__core-js_shared__"],pn=function(){var t=/[^.]+$/.exec(hn&&hn.keys&&hn.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""}(),dn=Function.prototype,vn=dn.toString,bn=/[\\^$.*+?()[\]{}|]/g,yn=/^\[object .+?Constructor\]$/,gn=Function.prototype,_n=Object.prototype,mn=gn.toString,wn=_n.hasOwnProperty,jn=RegExp("^"+mn.call(wn).replace(bn,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),xn=function(){try{var t=U(Object,"defineProperty");return t({},"",{}),t}catch(t){}}(),On=Object.prototype,Pn=On.hasOwnProperty,$n=Math.max,An=xn?function(t,e){return xn(t,"toString",{configurable:!0,enumerable:!1,value:J(e),writable:!0})}:H,kn=800,Sn=16,Rn=Date.now,En=function(t){var e=0,r=0;return function(){var n=Rn(),o=Sn-(n-r);if(r=n,o>0){if(++e>=kn)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}(An),zn=9007199254740991,Cn=9007199254740991,Mn=/^(?:0|[1-9]\d*)$/,Dn="object"==typeof t&&t&&!t.nodeType&&t,Tn=Dn&&"object"==typeof module&&module&&!module.nodeType&&module,Bn=Tn&&Tn.exports===Dn,Nn=Bn?jr.Buffer:void 0,In=Nn?Nn.isBuffer:void 0,Fn=In||ot,Ln={};Ln["[object Float32Array]"]=Ln["[object Float64Array]"]=Ln["[object Int8Array]"]=Ln["[object Int16Array]"]=Ln["[object Int32Array]"]=Ln["[object Uint8Array]"]=Ln["[object Uint8ClampedArray]"]=Ln["[object Uint16Array]"]=Ln["[object Uint32Array]"]=!0,Ln["[object Arguments]"]=Ln["[object Array]"]=Ln["[object ArrayBuffer]"]=Ln["[object Boolean]"]=Ln["[object DataView]"]=Ln["[object Date]"]=Ln["[object Error]"]=Ln["[object Function]"]=Ln["[object Map]"]=Ln["[object Number]"]=Ln["[object Object]"]=Ln["[object RegExp]"]=Ln["[object Set]"]=Ln["[object String]"]=Ln["[object WeakMap]"]=!1;var Un="object"==typeof t&&t&&!t.nodeType&&t,qn=Un&&"object"==typeof module&&module&&!module.nodeType&&module,Qn=qn&&qn.exports===Un,Vn=Qn&&mr.process,Wn=function(){try{return Vn&&Vn.binding&&Vn.binding("util")}catch(t){}}(),Hn=Wn&&Wn.isTypedArray,Gn=Hn?function(t){return function(e){return t(e)}}(Hn):it,Kn=Object.prototype,Jn=Kn.hasOwnProperty,Xn=Object.prototype,Yn=Object.prototype,Zn=Yn.hasOwnProperty,to=rt(function(t,e){W(e,ft(e),t)}),eo=rt(function(t,e,r,n){W(e,ft(e),t,n)}),ro=function(t){return function(e,r,n){for(var o=-1,i=Object(e),u=n(e),a=u.length;a--;){var c=u[t?a:++o];if(!1===r(i[c],c,i))break}return e}}(),no=b(Object.keys,Object),oo=Object.prototype,io=oo.hasOwnProperty,uo=function(t,e){return function(r,n){if(null==r)return r;if(!Z(r))return t(r,n);for(var o=r.length,i=e?o:-1,u=Object(r);(e?i--:++i0;)r=r.ctx.$child.router;return r},t.update=function(e,o){return r(this,void 0,void 0,function(){return n(this,function(r){switch(r.label){case 0:return[4,t.head.update(e,o)];case 1:return[2,r.sent()]}})})},t.getPathFromLocation=function(){var e=location.pathname+location.search+location.hash,r=t.config.base.replace("#!","#?!?");return e.replace(new RegExp(r,"i"),"")},t.onclick=function(e){if(!e.defaultPrevented){for(var r=e.target;r&&"A"!==r.nodeName;)r=r.parentNode;if(r&&"A"===r.nodeName){var n=r.pathname,o=r.search,i=r.hash,u=void 0===i?"":i,a=(n+o+u).replace(new RegExp(t.base,"i"),""),c=t.hasRoute(a),s=!t.sameOrigin(r.href),f=1!==t.which(e),l=r.hasAttribute("download"),h="#"===r.getAttribute("href"),p=0===(r.getAttribute("href")||"").indexOf("mailto:"),d="external"===r.getAttribute("rel"),v=e.metaKey||e.ctrlKey||e.shiftKey,b=r.hasAttribute("target");!c||s||f||l||h||p||d||v||b||(t.update(a),e.preventDefault())}}},t.onpopstate=function(e){t.update(t.getPathFromLocation(),!1),e.preventDefault()},t.canonicalizePath=function(t){return t.replace(new RegExp("/?#?!?/?"),"/")},t.parseUrl=function(e){var r=document.createElement("a"),n=t.base.toLowerCase();return n&&0===e.toLowerCase().indexOf(n)&&(e=e.replace(new RegExp(n,"i"),"")||"/"),r.href=t.canonicalizePath(e),{hash:r.hash,pathname:"/"===r.pathname.charAt(0)?r.pathname:"/"+r.pathname,search:r.search}},t.getPath=function(e){return t.parseUrl(e).pathname},t.hasRoute=function(e){return!v(t.head.resolveRoute(t.getPath(e)))},t.createRoutes=function(t){return xe(t,function(t,e){return new Ri(e,t)})},t.normalizeRoutes=function(e){return Pe(e,function(e){return xe(t.runPlugins(e),function(e){return g(e)?t.normalizeRoutes(e):e})})},t.runPlugins=function(e){return Oe(E(e),function(e){var r=ke(t.plugins,function(t,r){var n=r(e);return v(n)?t:t.concat(E(n))},[]);return r.length>0?r:e})},t.sameOrigin=function(t){var e=location.hostname,r=location.port,n=location.protocol,o=n+"//"+e;return r&&(o+=":"+r),t&&0===t.indexOf(o)},t.which=function(t){return t=t||window.event,null===t.which?t.button:t.which},t.onInit=[],t.middleware=[],t.plugins=[],t.config={base:"",hashbang:!1,activePathCSSClass:"active-path"},t.routes={},t.events={click:document.ontouchstart?"touchstart":"click",popstate:"popstate"},t}(),zi={init:function(t,r,n,o,i){var u=n.get("pathActiveClass")||Ei.config.activePathCSSClass,a=e.unwrap(r());Ei.initialized.then(function(){var r=Be(i),n=e.pureComputed(function(){return Re(r,a)});e.applyBindingsToNode(t,{css:(o={},o[u]=e.pureComputed(function(){return ze(n())}),o)});var o})}};e.bindingHandlers.activePath=zi;var Ci={init:function(t,r,n,o,i){var u=e.unwrap(r());zi.init.apply(this,arguments),Ei.initialized.then(function(){var r=Be(i),n=e.pureComputed(function(){return Re(r,u)});e.applyBindingsToNode(t,{attr:{href:e.pureComputed(function(){return Ee(n())})}})})}};e.bindingHandlers.path=Ci,e.components.register("ko-component-router",{synchronous:!0,viewModel:{createViewModel:Ke},template:'
\n
\n
'}),e.bindingHandlers.__ko_component_router__={init:function(t,r,n,o,i){var u=i.$rawData;return e.applyBindingsToNode(t,{css:u.component,component:{name:u.component,params:u.ctx}},i.extend({$router:u})),u.isRoot?u.init():u.ctx.$parent.router.initialized.then(function(){return u.init()}),{controlsDescendantBindings:!0}}},t.Context=wi,t.Route=Ri,t.Router=Ei,t.isActivePath=ze,t.resolveHref=Ee,Object.defineProperty(t,"__esModule",{value:!0})}); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ko-component-router", 3 | "version": "5.0.1", 4 | "description": "Component-based routing for KnockoutJS", 5 | "author": "Casey Webb ", 6 | "license": "WTFPL", 7 | "homepage": "https://github.com/Profiscience/ko-component-router", 8 | "bugs": "https://github.com/Profiscience/ko-component-router/issues", 9 | "repository": "Profiscience/ko-component-router", 10 | "main": "ko-component-router.js", 11 | "jsnext:main": "dist/index.js", 12 | "module": "dist/index.js", 13 | "typings": "dist/index.d.ts", 14 | "files": [ 15 | "dist/", 16 | "ko-component-router.js" 17 | ], 18 | "scripts": { 19 | "build": "taskr build", 20 | "watch": "taskr watch", 21 | "test": "taskr test", 22 | "test:watch": "taskr test:watch", 23 | "prepublish": "yarn build" 24 | }, 25 | "keywords": [ 26 | "knockoutjs", 27 | "knockout", 28 | "ko", 29 | "component", 30 | "router", 31 | "routing", 32 | "spa", 33 | "framework" 34 | ], 35 | "dependencies": { 36 | "core-js": "^2.3.0", 37 | "lodash-es": "^4.14.0", 38 | "path-to-regexp": "^2.0.0", 39 | "tslib": "^1.7.0" 40 | }, 41 | "peerDependencies": { 42 | "knockout": "^3.4.0" 43 | }, 44 | "devDependencies": { 45 | "@taskr/clear": "^1.1.0", 46 | "@taskr/uglify": "^1.1.0", 47 | "@taskr/watch": "^1.1.0", 48 | "@types/knockout": "^3.4.0", 49 | "@types/lodash-es": "^4.14.4", 50 | "chalk": "^2.0.0", 51 | "eslint": "^4.9.0", 52 | "eslint-config-profiscience": "^2.0.4", 53 | "execa": "^0.8.0", 54 | "jquery": "^3.1.1", 55 | "karma": "^1.6.0", 56 | "karma-chrome-launcher": "^2.0.0", 57 | "karma-firefox-launcher": "^1.0.1", 58 | "karma-mocha-reporter": "^2.2.2", 59 | "karma-remap-istanbul": "^0.6.0", 60 | "karma-rollup-preprocessor": "^5.0.1", 61 | "karma-tap": "^3.1.1", 62 | "knockout": "^3.3.0", 63 | "lodash": "^4.17.4", 64 | "rollup": "^0.50.0", 65 | "rollup-plugin-commonjs": "^8.0.2", 66 | "rollup-plugin-istanbul": "^1.1.0", 67 | "rollup-plugin-json": "^2.3.0", 68 | "rollup-plugin-node-builtins": "^2.1.0", 69 | "rollup-plugin-node-globals": "^1.1.0", 70 | "rollup-plugin-node-resolve": "^3.0.0", 71 | "tape": "^4.8.0", 72 | "taskr": "^1.1.0", 73 | "taskr-rename": "^0.0.1", 74 | "taskr-rollup": "^0.0.3", 75 | "ts-node": "^3.3.0", 76 | "tslint": "^5.8.0", 77 | "typescript": "^2.5.3" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/bindings/active-path.ts: -------------------------------------------------------------------------------- 1 | import * as ko from 'knockout' 2 | import { Router } from '../router' 3 | import { isActivePath, traversePath, getRouterForBindingContext } from '../utils' 4 | 5 | export const activePathBinding: KnockoutBindingHandler = { 6 | init(el, valueAccessor, allBindings, viewModel, bindingCtx) { 7 | const activePathCSSClass = allBindings.get('pathActiveClass') || Router.config.activePathCSSClass 8 | const path = ko.unwrap(valueAccessor()) 9 | 10 | Router.initialized.then(() => { 11 | const router = getRouterForBindingContext(bindingCtx) 12 | const route = ko.pureComputed(() => traversePath(router, path)) 13 | ko.applyBindingsToNode(el, { 14 | css: { 15 | [activePathCSSClass]: ko.pureComputed(() => isActivePath(route())) 16 | } 17 | }) 18 | }) 19 | } 20 | } 21 | 22 | ko.bindingHandlers.activePath = activePathBinding 23 | -------------------------------------------------------------------------------- /src/bindings/index.ts: -------------------------------------------------------------------------------- 1 | import './path' 2 | import './active-path' 3 | -------------------------------------------------------------------------------- /src/bindings/path.ts: -------------------------------------------------------------------------------- 1 | import * as ko from 'knockout' 2 | import { Router } from '../router' 3 | import { resolveHref, traversePath, getRouterForBindingContext } from '../utils' 4 | import { activePathBinding } from './active-path' 5 | 6 | export const pathBinding: KnockoutBindingHandler = { 7 | init(el, valueAccessor, allBindings, viewModel, bindingCtx) { 8 | const path = ko.unwrap(valueAccessor()) 9 | 10 | activePathBinding.init.apply(this, arguments) 11 | 12 | Router.initialized.then(() => { 13 | const router = getRouterForBindingContext(bindingCtx) 14 | const route = ko.pureComputed(() => traversePath(router, path)) 15 | ko.applyBindingsToNode(el, { 16 | attr: { 17 | href: ko.pureComputed(() => resolveHref(route())) 18 | } 19 | }) 20 | }) 21 | } 22 | } 23 | 24 | ko.bindingHandlers.path = pathBinding 25 | -------------------------------------------------------------------------------- /src/component.ts: -------------------------------------------------------------------------------- 1 | import * as ko from 'knockout' 2 | import { Router } from './router' 3 | import { map, traversePath } from './utils' 4 | 5 | declare global { 6 | // tslint:disable-next-line interface-name 7 | interface KnockoutBindingContext { 8 | $router: Router 9 | } 10 | } 11 | 12 | ko.components.register('ko-component-router', { 13 | synchronous: true, 14 | viewModel: { createViewModel }, 15 | template: 16 | `
17 |
18 |
` 19 | }) 20 | 21 | ko.bindingHandlers.__ko_component_router__ = { 22 | init(el, valueAccessor, allBindings, viewModel, bindingCtx) { 23 | 24 | const $router: Router = bindingCtx.$rawData 25 | 26 | ko.applyBindingsToNode(el, { 27 | css: $router.component, 28 | component: { 29 | name: $router.component, 30 | params: $router.ctx 31 | } 32 | }, bindingCtx.extend({ $router })) 33 | 34 | if ($router.isRoot) { 35 | $router.init() 36 | } else { 37 | $router.ctx.$parent.router.initialized.then(() => $router.init()) 38 | } 39 | 40 | return { controlsDescendantBindings: true } 41 | } 42 | } 43 | 44 | function createViewModel(params: { [k: string]: any }) { 45 | let router = Router.head 46 | if (!router) { 47 | router = new Router(Router.getPathFromLocation(), undefined, params) 48 | } else { 49 | while (router.bound) { 50 | router = router.ctx.$child.router 51 | } 52 | } 53 | router.bound = true 54 | 55 | if (router.isRoot) { 56 | router.ctx.runBeforeRender() 57 | .then(() => { 58 | if (router.ctx._redirect) { 59 | router.ctx.runAfterRender().then(() => { 60 | const { router: r, path: p } = traversePath(router, router.ctx._redirect) 61 | r.update(p, router.ctx._redirectArgs) 62 | }) 63 | } else { 64 | router.ctx.render() 65 | map(Router.onInit, (resolve) => resolve(router)) 66 | } 67 | }) 68 | } else if (router.ctx._redirect) { 69 | const { router: r, path: p } = traversePath(router, router.ctx._redirect) 70 | setTimeout(() => r.update(p, router.ctx._redirectArgs)) 71 | } 72 | 73 | return router 74 | } 75 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/es7/symbol' 2 | import * as ko from 'knockout' 3 | import { IContext } from './' 4 | import { Route } from './route' 5 | import { Router, Middleware } from './router' 6 | import { 7 | Callback, 8 | isThenable, isUndefined, 9 | concat, 10 | extend, 11 | map, 12 | castLifecycleObjectMiddlewareToGenerator, 13 | sequence 14 | } from './utils' 15 | 16 | export class Context implements IContext { 17 | public $child: Context & IContext 18 | public $parent: Context & IContext 19 | public router: Router 20 | public route: Route 21 | public params: { [k: string]: any } 22 | public path: string 23 | public pathname: string 24 | public _redirect: string 25 | public _redirectArgs: { 26 | push: false 27 | force?: boolean 28 | with?: { [prop: string]: any } 29 | } 30 | 31 | private _queue: Promise[] = [] 32 | private _beforeNavigateCallbacks: Callback[] = [] 33 | private _appMiddlewareDownstream: Callback[] = [] 34 | private _routeMiddlewareDownstream: Callback[] = [] 35 | 36 | constructor(router: Router, $parent: Context, path: string, _with: { [key: string]: any } = {}) { 37 | const route = router.resolveRoute(path) 38 | const { params, pathname, childPath } = route.parse(path) 39 | 40 | extend(this, { 41 | $parent, 42 | params, 43 | path, 44 | pathname, 45 | route, 46 | router 47 | }, _with) 48 | 49 | if ($parent) { 50 | $parent.$child = this 51 | } 52 | if (childPath) { 53 | // tslint:disable-next-line no-unused-expression 54 | new Router(childPath, this).ctx 55 | } 56 | } 57 | 58 | public addBeforeNavigateCallback(cb: Callback) { 59 | this._beforeNavigateCallbacks.unshift(cb) 60 | } 61 | 62 | public get base(): string { 63 | return this.router.isRoot 64 | ? Router.base 65 | : this.$parent.base + this.$parent.pathname 66 | } 67 | 68 | // full path w/o base 69 | public get canonicalPath() { 70 | return this.base.replace(new RegExp(this.$root.base, 'i'), '') + this.pathname 71 | } 72 | 73 | public get $root(): Context & IContext { 74 | let ctx: Context & IContext = this 75 | while (ctx) { 76 | if (ctx.$parent) { 77 | ctx = ctx.$parent 78 | } else { 79 | return ctx 80 | } 81 | } 82 | } 83 | 84 | public get $parents(): (Context & IContext)[] { 85 | const parents = [] 86 | let parent = this.$parent 87 | while (parent) { 88 | parents.push(parent) 89 | parent = parent.$parent 90 | } 91 | return parents 92 | } 93 | 94 | public get $children(): (Context & IContext)[] { 95 | const children = [] 96 | let child = this.$child 97 | while (child) { 98 | children.push(child) 99 | child = child.$child 100 | } 101 | return children 102 | } 103 | 104 | public queue(promise: Promise) { 105 | this._queue.push(promise) 106 | } 107 | 108 | public redirect(path: string, args: { [k: string]: any } = {}) { 109 | this._redirect = path 110 | this._redirectArgs = extend({}, args, { push: false as false }) 111 | } 112 | 113 | public async runBeforeNavigateCallbacks(): Promise { 114 | let ctx: Context = this 115 | let callbacks: Callback[] = [] 116 | while (ctx) { 117 | callbacks = [...ctx._beforeNavigateCallbacks, ...callbacks] 118 | ctx = ctx.$child 119 | } 120 | const { success } = await sequence(callbacks) 121 | return success 122 | } 123 | 124 | public render() { 125 | let ctx: Context = this 126 | while (ctx) { 127 | if (isUndefined(ctx._redirect)) { 128 | ctx.router.component(ctx.route.component) 129 | } 130 | ctx = ctx.$child 131 | } 132 | ko.tasks.runEarly() 133 | } 134 | 135 | public async runBeforeRender(flush = true) { 136 | const appMiddlewareDownstream = Context.runMiddleware(Router.middleware, this) 137 | const routeMiddlewareDownstream = Context.runMiddleware(this.route.middleware, this) 138 | 139 | const { count: numAppMiddlewareRanPreRedirect } = await sequence(appMiddlewareDownstream) 140 | const { count: numRouteMiddlewareRanPreRedirect } = await sequence(routeMiddlewareDownstream) 141 | 142 | this._appMiddlewareDownstream = appMiddlewareDownstream.slice(0, numAppMiddlewareRanPreRedirect) 143 | this._routeMiddlewareDownstream = routeMiddlewareDownstream.slice(0, numRouteMiddlewareRanPreRedirect) 144 | 145 | if (this.$child && isUndefined(this._redirect)) { 146 | await this.$child.runBeforeRender(false) 147 | } 148 | if (flush) { 149 | await this.flushQueue() 150 | } 151 | } 152 | 153 | public async runAfterRender() { 154 | await sequence(concat(this._appMiddlewareDownstream, this._routeMiddlewareDownstream)) 155 | await this.flushQueue() 156 | } 157 | 158 | public async runBeforeDispose(flush = true) { 159 | if (this.$child && isUndefined(this._redirect)) { 160 | await this.$child.runBeforeDispose(false) 161 | } 162 | await sequence(concat(this._routeMiddlewareDownstream, this._appMiddlewareDownstream)) 163 | if (flush) { 164 | await this.flushQueue() 165 | } 166 | } 167 | 168 | public async runAfterDispose(flush = true) { 169 | if (this.$child && isUndefined(this._redirect)) { 170 | await this.$child.runAfterDispose(false) 171 | } 172 | await sequence(concat(this._routeMiddlewareDownstream, this._appMiddlewareDownstream)) 173 | if (flush) { 174 | await this.flushQueue() 175 | } 176 | } 177 | 178 | private async flushQueue() { 179 | const thisQueue = Promise.all(this._queue).then(() => { 180 | this._queue = [] 181 | }) 182 | const childQueues = map(this.$children, (c) => c.flushQueue()) 183 | await Promise.all>([thisQueue, ...childQueues]) 184 | } 185 | 186 | private static runMiddleware(middleware: Middleware[], ctx: Context): Callback[] { 187 | return map(middleware, (fn) => { 188 | const runner = castLifecycleObjectMiddlewareToGenerator(fn)(ctx) 189 | let beforeRender = true 190 | return async () => { 191 | const ret = runner.next() 192 | if (isThenable(ret)) { 193 | await ret 194 | } else if (isThenable((ret as IteratorResult | void>).value)) { 195 | await (ret as IteratorResult | void>).value 196 | } 197 | if (beforeRender) { 198 | // this should only block the sequence for the first call, 199 | // and allow cleanup after the redirect 200 | beforeRender = false 201 | return isUndefined(ctx._redirect) 202 | } else { 203 | return true 204 | } 205 | } 206 | }) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './bindings' 2 | import './component' 3 | 4 | export { Context } from './context' 5 | export { Route, RouteConfig } from './route' 6 | export { Router, RouteMap, Middleware, Plugin } from './router' 7 | export { isActivePath, resolveHref } from './utils' 8 | 9 | /** 10 | * IContext exists for the sole purpose that classes do not support declaration 11 | * merging. 12 | * 13 | * See https://github.com/Microsoft/TypeScript/issues/9532 for why this is here, and 14 | * not ./context.ts where it belongs... 15 | * 16 | * tl;dr, re-exported interfaces can't be declared w/ the top level name, so 17 | * to augment (declaration merging) IContext in consumer code, you'd need to do... 18 | * 19 | * declare module "ko-component-router/dist/typings/context" { 20 | * // ... 21 | * } 22 | * 23 | * and IMO that's just bad; at least way worse than this. 24 | */ 25 | export interface IContext {} // tslint:disable-line no-empty-interface 26 | -------------------------------------------------------------------------------- /src/route.ts: -------------------------------------------------------------------------------- 1 | import pathtoRegexp from 'path-to-regexp' 2 | import { RouteMap, Middleware } from './router' 3 | import { isFunction, isPlainObject, isString, isUndefined, map, reduce } from './utils' 4 | 5 | export type RouteConfig = string | RouteMap | Middleware 6 | 7 | export class Route { 8 | public path: string 9 | public component: string 10 | public middleware: Middleware[] 11 | public children: Route[] 12 | public keys: pathtoRegexp.Key[] 13 | 14 | private regexp: RegExp 15 | 16 | constructor(path: string, config: RouteConfig[]) { 17 | const [component, middleware, children] = Route.parseConfig(config) 18 | this.path = path 19 | this.component = component 20 | this.middleware = middleware 21 | this.children = children 22 | 23 | const { keys, regexp } = Route.parsePath(path, !isUndefined(children)) 24 | this.keys = keys 25 | this.regexp = regexp 26 | } 27 | 28 | public matches(path: string) { 29 | const matches = this.regexp.exec(path) 30 | if (matches === null) { 31 | return false 32 | } 33 | if (this.children) { 34 | for (const childRoute of this.children) { 35 | const childPath = '/' + (matches[matches.length - 1] || '') 36 | if (childRoute.matches(childPath)) { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | return true 43 | } 44 | 45 | public parse(path: string): { params: { [k: string]: any }, pathname: string, childPath: string } { 46 | let childPath 47 | let pathname = path 48 | const params: { [k: string]: any } = {} 49 | const matches = this.regexp.exec(path) 50 | 51 | for (let i = 1, len = matches.length; i < len; ++i) { 52 | const k = this.keys[i - 1] 53 | const v = matches[i] || '' 54 | if (k.name === '__child_path__') { 55 | childPath = '/' + v 56 | pathname = path.replace(new RegExp(childPath + '$'), '') 57 | } else { 58 | params[k.name] = v 59 | } 60 | } 61 | 62 | return { params, pathname, childPath } 63 | } 64 | 65 | private static parseConfig(config: (string | RouteMap | Middleware)[]): [string, Middleware[], Route[]] { 66 | let component: string 67 | let children: Route[] 68 | 69 | const middleware = reduce( 70 | config, 71 | ( 72 | accum: Middleware[], 73 | m: string | RouteMap | Middleware 74 | ) => { 75 | if (isString(m)) { 76 | m = m as string 77 | component = m 78 | } else if (isPlainObject(m)) { 79 | m = m as RouteMap 80 | children = map(m, (routeConfig, path) => new Route(path, routeConfig)) 81 | if (!component) { 82 | component = 'ko-component-router' 83 | } 84 | } else if (isFunction(m)) { 85 | m = m as Middleware 86 | accum.push(m) 87 | } 88 | return accum 89 | }, []) 90 | 91 | return [component, middleware, children] 92 | } 93 | 94 | private static parsePath(path: string, hasChildren: boolean) { 95 | if (hasChildren) { 96 | path = path.replace(/\/?!?$/, '/!') 97 | } 98 | 99 | if (path[path.length - 1] === '!') { 100 | path = path.replace('!', ':__child_path__(.*)?') 101 | } else { 102 | path = path.replace(/\(?\*\)?/, '(.*)') 103 | } 104 | 105 | const keys: pathtoRegexp.Key[] = [] 106 | const regexp = pathtoRegexp(path, keys) 107 | 108 | return { keys, regexp } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import * as ko from 'knockout' 2 | import { IContext } from './' 3 | import { Context } from './context' 4 | import { Route, RouteConfig } from './route' 5 | import { 6 | Callback, 7 | isBoolean, isPlainObject, isUndefined, 8 | castArray, 9 | extend, extendWith, 10 | flatMap, map, mapValues, 11 | reduce, 12 | traversePath 13 | } from './utils' 14 | 15 | export type SimpleMiddleware = (ctx: Context & IContext, done?: () => any) => 16 | | Promise 17 | | void 18 | 19 | export type LifecycleObjectMiddleware = (ctx: Context & IContext, done?: () => any) => { 20 | beforeRender?: Callback 21 | afterRender ?: Callback 22 | beforeDispose?: Callback 23 | afterDispose?: Callback 24 | } 25 | 26 | export type LifecycleGeneratorMiddleware = (ctx: Context & IContext, done?: () => any) => 27 | | IterableIterator> 28 | | AsyncIterableIterator> 29 | 30 | export type Middleware = SimpleMiddleware | LifecycleObjectMiddleware | LifecycleGeneratorMiddleware 31 | 32 | export type Plugin = (routeConfig: any) => RouteConfig 33 | 34 | export type RouteMap = { 35 | [name: string]: RouteConfig[] 36 | } 37 | 38 | export class Router { 39 | public static head: Router 40 | public static onInit: ((router: Router) => void)[] = [] 41 | public static middleware: Middleware[] = [] 42 | public static plugins: Plugin[] = [] 43 | public static config: { 44 | base?: string 45 | hashbang?: boolean 46 | activePathCSSClass?: string 47 | } = { 48 | base: '', 49 | hashbang: false, 50 | activePathCSSClass: 'active-path' 51 | } 52 | 53 | private static routes: RouteMap = {} 54 | private static events: { 55 | click: string, 56 | popstate: string 57 | } = { 58 | click: document.ontouchstart ? 'touchstart' : 'click', 59 | popstate: 'popstate' 60 | } 61 | 62 | public onInit: (() => void)[] = [] 63 | public component: KnockoutObservable 64 | public isNavigating: KnockoutObservable 65 | public routes: Route[] 66 | public isRoot: boolean 67 | public ctx: Context & IContext 68 | public bound: boolean 69 | 70 | constructor( 71 | url: string, 72 | $parentCtx?: Context, 73 | _with: { [k: string]: any } = {} 74 | ) { 75 | this.component = ko.observable(null) 76 | this.isNavigating = ko.observable(true) 77 | this.isRoot = isUndefined($parentCtx) 78 | this.routes = this.isRoot 79 | ? Router.createRoutes(Router.routes) 80 | : $parentCtx.route.children 81 | 82 | if (this.isRoot) { 83 | Router.head = this 84 | document.addEventListener(Router.events.click, Router.onclick) 85 | window.addEventListener(Router.events.popstate, Router.onpopstate) 86 | } 87 | 88 | this.ctx = new Context(this, $parentCtx, Router.getPath(url), _with) 89 | } 90 | 91 | get initialized(): Promise { 92 | if (this.isNavigating()) { 93 | return new Promise((resolve) => this.onInit.push(resolve)) 94 | } else { 95 | return Promise.resolve(this) 96 | } 97 | } 98 | 99 | public init() { 100 | this.isNavigating(false) 101 | this.ctx.runAfterRender().then(() => { 102 | const resolveRouter = (router: Router) => (resolve: typeof Promise.resolve) => resolve(router) 103 | let ctx = this.ctx 104 | while (ctx) { 105 | map(ctx.router.onInit, resolveRouter(ctx.router)) 106 | ctx = ctx.$child 107 | } 108 | }) 109 | } 110 | 111 | public async update( 112 | url: string, 113 | _args?: boolean | { 114 | push?: boolean 115 | force?: boolean 116 | with?: { [prop: string]: any } 117 | }): Promise { 118 | let args 119 | if (isBoolean(_args)) { 120 | args = { push: _args as boolean } 121 | } else if (isUndefined(_args)) { 122 | args = {} 123 | } else { 124 | args = _args 125 | } 126 | if (isUndefined(args.push)) { 127 | args.push = true 128 | } 129 | if (isUndefined(args.with)) { 130 | args.with = {} 131 | } 132 | 133 | const fromCtx = this.ctx 134 | const { search, hash } = Router.parseUrl(url) 135 | const path = Router.getPath(url) 136 | const route = this.resolveRoute(path) 137 | const { pathname, childPath } = route.parse(path) 138 | const samePage = fromCtx.pathname === pathname 139 | 140 | if (fromCtx.$child && samePage && !args.force) { 141 | return await fromCtx.$child.router.update(childPath + search + hash, args) 142 | } 143 | 144 | const toCtx = new Context(this, this.ctx.$parent, path, args.with) 145 | 146 | if (!toCtx.route) { 147 | return false 148 | } 149 | 150 | const shouldNavigate = await fromCtx.runBeforeNavigateCallbacks() 151 | if (shouldNavigate === false) { 152 | return false 153 | } 154 | 155 | this.isNavigating(true) 156 | 157 | await fromCtx.runBeforeDispose() 158 | 159 | history[args.push ? 'pushState' : 'replaceState']( 160 | history.state, 161 | document.title, 162 | toCtx.base + toCtx.path + search + hash 163 | ) 164 | 165 | await toCtx.runBeforeRender() 166 | 167 | if (isUndefined(toCtx._redirect)) { 168 | this.component(null) 169 | ko.tasks.runEarly() 170 | } 171 | 172 | this.ctx = toCtx 173 | 174 | await fromCtx.runAfterDispose() 175 | 176 | toCtx.render() 177 | 178 | if (!isUndefined(toCtx._redirect)) { 179 | await toCtx.runAfterRender() 180 | const { router: r, path: p } = traversePath(toCtx.router, toCtx._redirect) 181 | r.update(p, toCtx._redirectArgs) 182 | } 183 | 184 | return true 185 | } 186 | 187 | public resolveRoute(path: string): Route { 188 | let matchingRouteWithFewestDynamicSegments 189 | let fewestMatchingSegments = Infinity 190 | 191 | for (const rn in this.routes) { 192 | if (this.routes.hasOwnProperty(rn)) { 193 | const r = this.routes[rn] 194 | if (r.matches(path)) { 195 | if (r.keys.length === 0) { 196 | return r 197 | } else if (fewestMatchingSegments === Infinity || 198 | (r.keys.length < fewestMatchingSegments && r.keys[0].pattern !== '.*')) { 199 | fewestMatchingSegments = r.keys.length 200 | matchingRouteWithFewestDynamicSegments = r 201 | } 202 | } 203 | } 204 | } 205 | return matchingRouteWithFewestDynamicSegments 206 | } 207 | 208 | public dispose() { 209 | if (this.isRoot) { 210 | document.removeEventListener(Router.events.click, Router.onclick, false) 211 | window.removeEventListener(Router.events.popstate, Router.onpopstate, false) 212 | delete Router.head 213 | } 214 | } 215 | 216 | static get initialized(): Promise { 217 | if (Router.head) { 218 | return Promise.resolve(Router.head) 219 | } else { 220 | return new Promise((resolve) => this.onInit.push(resolve)) 221 | } 222 | } 223 | 224 | static get base(): string { 225 | return Router.config.base + (Router.config.hashbang ? '/#!' : '') 226 | } 227 | 228 | public static setConfig({ base, hashbang, activePathCSSClass }: { 229 | base?: string 230 | hashbang?: boolean 231 | activePathCSSClass?: string 232 | }) { 233 | extendWith(Router.config, { 234 | base, 235 | hashbang, 236 | activePathCSSClass 237 | }, (_default, v) => isUndefined(v) ? _default : v) 238 | } 239 | 240 | public static use(...fns: Middleware[]) { 241 | Router.middleware.push(...fns) 242 | } 243 | 244 | public static usePlugin(...fns: Plugin[]) { 245 | Router.plugins.push(...fns) 246 | } 247 | 248 | public static useRoutes(routes: { [route: string]: any }) { 249 | extend(Router.routes, Router.normalizeRoutes(routes)) 250 | } 251 | 252 | public static get(i: number): Router { 253 | let router = Router.head 254 | while (i-- > 0) { 255 | router = router.ctx.$child.router 256 | } 257 | return router 258 | } 259 | 260 | public static async update( 261 | url: string, 262 | _args?: boolean | { 263 | push?: boolean 264 | force?: boolean 265 | with?: { [prop: string]: any } 266 | }): Promise { 267 | return await Router.head.update(url, _args) 268 | } 269 | 270 | public static getPathFromLocation(): string { 271 | const path = location.pathname + location.search + location.hash 272 | const baseWithOrWithoutHashbangRegexp = Router.config.base.replace('#!', '#?!?') 273 | return path.replace(new RegExp(baseWithOrWithoutHashbangRegexp, 'i'), '') 274 | } 275 | 276 | private static onclick(e: MouseEvent) { 277 | if (e.defaultPrevented) { 278 | return 279 | } 280 | 281 | let el: HTMLAnchorElement = e.target as HTMLAnchorElement 282 | while (el && el.nodeName !== 'A') { 283 | el = el.parentNode as HTMLAnchorElement 284 | } 285 | if (!el || el.nodeName !== 'A') { 286 | return 287 | } 288 | 289 | const { pathname, search, hash = '' } = el 290 | const path = (pathname + search + hash).replace(new RegExp(Router.base, 'i'), '') 291 | 292 | const isValidRoute = Router.hasRoute(path) 293 | const isCrossOrigin = !Router.sameOrigin(el.href) 294 | const isDoubleClick = Router.which(e) !== 1 295 | const isDownload = el.hasAttribute('download') 296 | const isEmptyHash = el.getAttribute('href') === '#' 297 | const isMailto = (el.getAttribute('href') || '').indexOf('mailto:') === 0 298 | const hasExternalRel = el.getAttribute('rel') === 'external' 299 | const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey 300 | const hasOtherTarget = el.hasAttribute('target') 301 | 302 | if (!isValidRoute || 303 | isCrossOrigin || 304 | isDoubleClick || 305 | isDownload || 306 | isEmptyHash || 307 | isMailto || 308 | hasExternalRel || 309 | hasModifier || 310 | hasOtherTarget) { 311 | return 312 | } 313 | 314 | Router.update(path) 315 | e.preventDefault() 316 | } 317 | 318 | private static onpopstate(e: PopStateEvent) { 319 | Router.update(Router.getPathFromLocation(), false) 320 | e.preventDefault() 321 | } 322 | 323 | private static canonicalizePath(path: string) { 324 | return path.replace(new RegExp('/?#?!?/?'), '/') 325 | } 326 | 327 | private static parseUrl(url: string) { 328 | const parser = document.createElement('a') 329 | const b = Router.base.toLowerCase() 330 | if (b && url.toLowerCase().indexOf(b) === 0) { 331 | url = url.replace(new RegExp(b, 'i'), '') || '/' 332 | } 333 | parser.href = Router.canonicalizePath(url) 334 | return { 335 | hash: parser.hash, 336 | pathname: (parser.pathname.charAt(0) === '/') 337 | ? parser.pathname 338 | : '/' + parser.pathname, 339 | search: parser.search 340 | } 341 | } 342 | 343 | private static getPath(url: string) { 344 | return Router.parseUrl(url).pathname 345 | } 346 | 347 | private static hasRoute(path: string) { 348 | return !isUndefined(Router.head.resolveRoute(Router.getPath(path))) 349 | } 350 | 351 | private static createRoutes(routes: RouteMap): Route[] { 352 | return map(routes, (config, path) => new Route(path, config)) 353 | } 354 | 355 | private static normalizeRoutes(routes: { [route: string]: any }): RouteMap { 356 | return mapValues(routes, (c) => 357 | map(Router.runPlugins(c), (routeConfig) => 358 | isPlainObject(routeConfig) 359 | ? Router.normalizeRoutes(routeConfig as RouteMap) 360 | : routeConfig)) 361 | } 362 | 363 | private static runPlugins(config: any): RouteConfig[] { 364 | return flatMap(castArray(config), (rc) => { 365 | const routeConfig = reduce( 366 | Router.plugins, 367 | (accum, plugin: Plugin) => { 368 | const prc = plugin(rc) 369 | return isUndefined(prc) ? accum : accum.concat(castArray(prc)) 370 | } 371 | , [] 372 | ) 373 | return routeConfig.length > 0 374 | ? routeConfig 375 | : rc 376 | }) 377 | } 378 | 379 | private static sameOrigin(href: string) { 380 | const { hostname, port, protocol } = location 381 | let origin = protocol + '//' + hostname 382 | if (port) { 383 | origin += ':' + port 384 | } 385 | return href && href.indexOf(origin) === 0 386 | } 387 | 388 | private static which(e: MouseEvent): number { 389 | e = e || window.event as MouseEvent 390 | return e.which === null ? e.button : e.which 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import isFunction from 'lodash-es/isFunction' 2 | import isUndefined from 'lodash-es/isUndefined' 3 | import isPlainObject from 'lodash-es/isPlainObject' 4 | import noop from 'lodash-es/noop' 5 | import startsWith from 'lodash-es/startsWith' 6 | import { Context } from './context' 7 | import { Router, Middleware, LifecycleGeneratorMiddleware } from './router' 8 | 9 | export { default as isArray } from 'lodash-es/isArray' 10 | export { default as isBoolean } from 'lodash-es/isBoolean' 11 | export { default as isFunction } from 'lodash-es/isFunction' 12 | export { default as isPlainObject } from 'lodash-es/isPlainObject' 13 | export { default as isString } from 'lodash-es/isString' 14 | export { default as isUndefined } from 'lodash-es/isUndefined' 15 | export { default as castArray } from 'lodash-es/castArray' 16 | export { default as concat } from 'lodash-es/concat' 17 | export { default as extend } from 'lodash-es/extend' 18 | export { default as extendWith } from 'lodash-es/extendWith' 19 | export { default as filter } from 'lodash-es/filter' 20 | export { default as flatMap } from 'lodash-es/flatMap' 21 | export { default as map } from 'lodash-es/map' 22 | export { default as mapValues } from 'lodash-es/mapValues' 23 | export { default as reduce } from 'lodash-es/reduce' 24 | 25 | export type AsyncCallback = (done?: (t: T) => void) => Promise | void 26 | export type SyncCallback = () => T 27 | export type Callback = AsyncCallback | SyncCallback 28 | 29 | export async function sequence(callbacks: Callback[], ...args: any[]): Promise<{ 30 | count: number, 31 | success: boolean 32 | }> { 33 | let count = 0 34 | let success = true 35 | for (const _fn of callbacks) { 36 | count++ 37 | const ret = await promisify(_fn)(...args) 38 | if (ret === false) { 39 | success = false 40 | break 41 | } 42 | } 43 | return { count, success } 44 | } 45 | 46 | export function traversePath(router: Router, path: string) { 47 | if (path.indexOf('//') === 0) { 48 | path = path.replace('//', '/') 49 | 50 | while (!router.isRoot) { 51 | router = router.ctx.$parent.router 52 | } 53 | } else { 54 | if (path.indexOf('./') === 0) { 55 | path = path.replace('./', '/') 56 | router = router.ctx.$child.router 57 | } 58 | 59 | while (path && path.match(/\/?\.\./i) && !router.isRoot) { 60 | router = router.ctx.$parent.router 61 | path = path.replace(/\/?\.\./i, '') 62 | } 63 | } 64 | 65 | return { router, path } 66 | } 67 | 68 | export function resolveHref({ router, path }: { router: Router, path: string }) { 69 | return router.ctx.base + path 70 | } 71 | 72 | export function isActivePath({ router, path }: { router: Router, path: string }): boolean { 73 | let ctx = router.ctx 74 | while (ctx) { 75 | // create dependency on isNavigating so that this works with nested routes 76 | // inside a computed 77 | ctx.router.isNavigating() 78 | 79 | if (ctx.$child ? startsWith(path, ctx.pathname) : path === ctx.pathname) { 80 | path = path.substr(ctx.pathname.length) || '/' 81 | ctx = ctx.$child 82 | } else { 83 | return false 84 | } 85 | } 86 | return true 87 | } 88 | 89 | export function isGenerator(x: any) { 90 | return x.constructor.name === 'GeneratorFunction' 91 | } 92 | 93 | export function isThenable(x: any) { 94 | return !isUndefined(x) && isFunction(x.then) 95 | } 96 | 97 | export function promisify(_fn: (...args: any[]) => void = noop): (...args: any[]) => Promise { 98 | return async (...args) => { 99 | const fn = () => 100 | _fn.length === args.length + 1 101 | ? new Promise((r) => { 102 | _fn(...args, r) 103 | }) 104 | : _fn(...args) 105 | 106 | const ret = fn() 107 | 108 | return isThenable(ret) 109 | ? await ret 110 | : ret 111 | } 112 | } 113 | 114 | export function castLifecycleObjectMiddlewareToGenerator(fn: Middleware): LifecycleGeneratorMiddleware { 115 | return isGenerator(fn) 116 | ? fn as LifecycleGeneratorMiddleware 117 | : async function *(ctx: Context) { 118 | const ret = await promisify(fn)(ctx) 119 | 120 | if (isPlainObject(ret)) { 121 | yield await promisify(ret.beforeRender)() 122 | yield await promisify(ret.afterRender)() 123 | yield await promisify(ret.beforeDispose)() 124 | yield await promisify(ret.afterDispose)() 125 | } else { 126 | yield ret 127 | } 128 | } 129 | } 130 | 131 | export function getRouterForBindingContext(bindingCtx: KnockoutBindingContext) { 132 | while (!isUndefined(bindingCtx)) { 133 | if (!isUndefined(bindingCtx.$router)) { 134 | return bindingCtx.$router 135 | } 136 | bindingCtx = bindingCtx.$parentContext 137 | } 138 | return Router.head 139 | } 140 | -------------------------------------------------------------------------------- /taskfile.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | Object.assign(exports, require('./tasks/test')) 4 | Object.assign(exports, require('./tasks/bundle')) 5 | exports.compile = require('./tasks/compile') 6 | exports.stats = require('./tasks/stats') 7 | 8 | exports.build = function* (task) { 9 | yield task.clear('dist') 10 | yield task.serial(['compile', 'bundle', 'stats']) 11 | } 12 | 13 | exports.watch = function * (task) { 14 | yield task.start('build') 15 | yield task.watch('src/*.ts', 'build') 16 | } 17 | -------------------------------------------------------------------------------- /tasks/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "profiscience", 3 | "parserOptions": { 4 | "sourceType": "script" 5 | } 6 | } -------------------------------------------------------------------------------- /tasks/bundle.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const nodeResolve = require('rollup-plugin-node-resolve') 5 | const commonjs = require('rollup-plugin-commonjs') 6 | 7 | let cache 8 | 9 | module.exports = { 10 | * 'bundle'(task) { 11 | yield task 12 | .source(path.resolve(__dirname, '../dist/index.js')) 13 | .rollup({ 14 | cache, 15 | external: ['knockout'], 16 | plugins: [ 17 | nodeResolve({ 18 | preferBuiltins: false 19 | }), 20 | commonjs() 21 | ], 22 | output: { 23 | file: 'ko-component-router.js', 24 | format: 'umd', 25 | exports: 'named', // const { Router, Route, Context, ... } = ko.router 26 | globals: { 27 | knockout: 'ko' 28 | }, 29 | name: 'ko.router' 30 | } 31 | }) 32 | .target(path.resolve(__dirname, '../')) 33 | 34 | .uglify() 35 | .rename({ suffix: '.min' }) 36 | .target(path.resolve(__dirname, '../')) 37 | } 38 | } -------------------------------------------------------------------------------- /tasks/compile.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const execa = require('execa') 4 | 5 | module.exports = function* () { 6 | yield execa('tsc', { stdio: 'inherit' }) 7 | } 8 | -------------------------------------------------------------------------------- /tasks/stats.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const { gzip } = require('zlib') 5 | const _ = require('lodash') 6 | const { green } = require('chalk') 7 | const { padEnd, padStart, round } = _ 8 | 9 | module.exports = function * (task) { 10 | const stats = [ 11 | yield getModuleStats(task), 12 | ...(yield getBundleStats(task)) 13 | ] 14 | 15 | const border = '-------------------------------------------------------------' 16 | const padNameWidth = 'ko-component-router.min.js'.length + 3 17 | const padUncompressedWidth = '~XXXkb'.length + 3 18 | const padRightWidth = border.length - 4 - padNameWidth - padUncompressedWidth 19 | console.log(green(border)) // eslint-disable-line no-console 20 | _(stats) 21 | .sortBy(([name]) => name) 22 | .each(([name, raw, gzipped]) => 23 | console.log(green( // eslint-disable-line no-console 24 | '|', 25 | ( 26 | padEnd(name, padNameWidth) + 27 | padStart(`~${raw}kb`, padUncompressedWidth) + 28 | padStart(`~${gzipped}kb gzipped`, padRightWidth) 29 | ), 30 | '|'))) 31 | console.log(green(border)) // eslint-disable-line no-console 32 | } 33 | 34 | async function getModuleStats(task) { 35 | let combined = '' 36 | await task 37 | .source(path.resolve(__dirname, '../dist/*.js')) 38 | .run({ every: true }, function * ({ data }) { // eslint-disable-line require-yield 39 | combined += data 40 | }) 41 | const kilobytes = round(Buffer.byteLength(combined, 'utf8') / 1000) 42 | const compressedKilobytes = await getGzippedSize(combined) 43 | return ['dist/*.js', kilobytes, compressedKilobytes] 44 | } 45 | 46 | async function getBundleStats(task) { 47 | const stats = [] 48 | await task 49 | .source(path.resolve(__dirname, '../ko-component-router.*')) 50 | .run({ every: true }, function * ({ base: name, data }) { 51 | const kilobytes = round(Buffer.byteLength(data, 'utf8') / 1000) 52 | const compressedKilobytes = yield getGzippedSize(data) 53 | stats.push([name, kilobytes, compressedKilobytes]) 54 | }) 55 | return stats 56 | } 57 | 58 | async function getGzippedSize(raw) { 59 | return await new Promise((resolve) => gzip(raw, (_, gzipped) => 60 | resolve(round(Buffer.byteLength(gzipped, 'utf8') / 1000)))) 61 | } -------------------------------------------------------------------------------- /tasks/test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const { Server } = require('karma') 5 | const nodeResolve = require('rollup-plugin-node-resolve') 6 | const nodeBuiltins = require('rollup-plugin-node-builtins') 7 | const nodeGlobals = require('rollup-plugin-node-globals') 8 | const commonjs = require('rollup-plugin-commonjs') 9 | const json = require('rollup-plugin-json') 10 | const rollupIstanbul = require('rollup-plugin-istanbul') 11 | 12 | let cache, watch, coverage 13 | 14 | module.exports = { 15 | * test(task) { 16 | coverage = true 17 | watch = false 18 | 19 | yield task.serial(['compile', 'karma']) 20 | }, 21 | * 'test:watch'(task) { 22 | coverage = false 23 | watch = true 24 | 25 | yield task.watch(path.resolve(__dirname, '../src/**/*.ts'), 'compile') 26 | yield task.serial(['compile', 'karma']) 27 | }, 28 | * karma() { // eslint-disable-line require-yield 29 | const config = { 30 | basePath: path.resolve(__dirname, '..'), 31 | 32 | frameworks: ['tap'], 33 | 34 | files: [ 35 | 'test/index.js' 36 | ], 37 | 38 | preprocessors: { 39 | 'test/index.js': 'rollup' 40 | }, 41 | 42 | browsers: [ 43 | process.env.TRAVIS ? '_Firefox' : '_Chrome' 44 | ], 45 | 46 | customLaunchers: { 47 | _Chrome: { 48 | base: 'Chrome', 49 | flags: ['--incognito'] 50 | }, 51 | _Firefox: { 52 | base: 'Firefox', 53 | flags: ['-private'] 54 | }, 55 | }, 56 | 57 | autoWatch: watch, 58 | 59 | singleRun: !watch, 60 | 61 | reporters: ['mocha', 'karma-remap-istanbul'], 62 | 63 | rollupPreprocessor: { 64 | cache, 65 | plugins: [ 66 | json(), 67 | commonjs({ 68 | namedExports: { 69 | knockout: [ 70 | 'applyBindings', 71 | 'applyBindingsToNode', 72 | 'bindingHandlers', 73 | 'components', 74 | 'observable', 75 | 'pureComputed', 76 | 'tasks', 77 | 'unwrap' 78 | ] 79 | } 80 | }), 81 | nodeGlobals(), 82 | nodeBuiltins(), 83 | nodeResolve({ 84 | preferBuiltins: true 85 | }) 86 | ], 87 | format: 'iife', 88 | sourcemap: 'inline' 89 | }, 90 | } 91 | 92 | if (coverage) { 93 | config.rollupPreprocessor.plugins.push( 94 | rollupIstanbul({ 95 | include: [ 96 | 'dist/**/*' 97 | ] 98 | })) 99 | 100 | config.remapIstanbulReporter = { 101 | reports: { 102 | lcovonly: 'coverage/lcov.info', 103 | html: 'coverage/html' 104 | } 105 | } 106 | } 107 | 108 | new Server(config, (code) => process.exit(code)).start() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "profiscience", 3 | "parserOptions": { 4 | "sourceType": "module" 5 | } 6 | } -------------------------------------------------------------------------------- /test/anchor.js: -------------------------------------------------------------------------------- 1 | import { map } from 'lodash-es' 2 | import ko from 'knockout' 3 | 4 | import { Router } from '../' 5 | 6 | const ignoredAnchors = [ 7 | 'x-origin', 8 | 'download', 9 | 'empty-hash', 10 | 'mail-to', 11 | 'external', 12 | 'target' 13 | ] 14 | 15 | ko.components.register('anchor', { 16 | template: ` 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | `, 26 | viewModel: class AnchorTest { 27 | constructor({ t, done }) { 28 | Router.useRoutes({ 29 | '/': 'root', 30 | '/a': 'a', 31 | '/b': 'b' 32 | }) 33 | 34 | ko.components.register('root', { 35 | template: '', 36 | viewModel: class { 37 | constructor() { 38 | ko.tasks.schedule(() => document.getElementById('absolute-a').click()) 39 | } 40 | } 41 | }) 42 | 43 | ko.components.register('a', { 44 | template: '', 45 | viewModel: class { 46 | constructor() { 47 | t.pass('can handle anchors with absolute paths') 48 | ko.tasks.schedule(() => document.getElementById('relative-b').click()) 49 | } 50 | } 51 | }) 52 | 53 | ko.components.register('b', { 54 | viewModel: class { 55 | constructor() { 56 | t.pass('can handle anchors with relative paths') 57 | ko.tasks.schedule(() => map(ignoredAnchors, (id) => document.getElementById(id).click())) 58 | } 59 | } 60 | }) 61 | 62 | let count = 0 63 | this.clickHandler = (e) => { 64 | if (!e.defaultPrevented) { 65 | t.ok(ignoredAnchors.indexOf(e.target.id) > -1, `ignores ${e.target.id} anchors`) 66 | e.preventDefault() 67 | 68 | if (++count === ignoredAnchors.length) { 69 | document.removeEventListener('click', this.clickHandler) 70 | done() 71 | } 72 | } 73 | } 74 | window.addEventListener('click', this.clickHandler) 75 | } 76 | } 77 | }) 78 | -------------------------------------------------------------------------------- /test/basepath.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import $ from 'jquery' 3 | 4 | import { Router } from '../' 5 | 6 | ko.components.register('basepath', { 7 | template: ` 8 | 9 | 10 | `, 11 | viewModel: class BasePath { 12 | constructor({ t, done }) { 13 | Router.setConfig({ 14 | base: '/base' 15 | }) 16 | 17 | Router.useRoutes({ 18 | '/foo': { 19 | '/foo': 'foo' 20 | }, 21 | '/bar': { 22 | '/bar': 'bar' 23 | } 24 | }) 25 | 26 | history.pushState(null, null, '/base/foo/foo') 27 | 28 | ko.components.register('foo', { 29 | viewModel: class { 30 | constructor(ctx) { 31 | t.pass('initializes with basepath') 32 | t.equals(location.pathname, '/base/foo/foo', 'uses basepath in url on init') 33 | t.equals(ctx.canonicalPath, '/foo/foo', 'ctx.canonicalPath is correct') 34 | 35 | ctx.router.initialized.then(() => setTimeout(() => { // Dirty hack for FF/TravisCI 36 | t.equals($('#foo-link').attr('href'), '/base/foo/foo', 'sets href correctly in path binding') 37 | Router.update('/bar/bar') 38 | })) 39 | } 40 | } 41 | }) 42 | 43 | ko.components.register('bar', { 44 | viewModel: class { 45 | constructor() { 46 | t.pass('navigates correctly with basepath') 47 | t.equals('/base/bar/bar', location.pathname, 'uses basepath in url on update') 48 | 49 | done() 50 | } 51 | } 52 | }) 53 | } 54 | 55 | dispose() { 56 | Router.setConfig({ 57 | base: '' 58 | }) 59 | } 60 | } 61 | }) 62 | -------------------------------------------------------------------------------- /test/before-navigate-callbacks.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | import { Router } from '../' 4 | 5 | ko.components.register('before-navigate-callbacks', { 6 | template: '', 7 | viewModel: class BeforeNavigateCallbackTest { 8 | constructor({ t, done }) { 9 | Router.useRoutes({ 10 | '/': 'empty', 11 | '/sync': 'sync', 12 | '/async-callback': 'async-callback', 13 | '/async-promise': 'async-promise', 14 | '/nested': [ 15 | 'nested', 16 | { 17 | '/': 'nested-child' 18 | } 19 | ] 20 | }) 21 | 22 | ko.components.register('empty', {}) 23 | 24 | setTimeout(() => this.runTests(t).then(done)) 25 | } 26 | 27 | async runTests(t) { 28 | let block 29 | 30 | history.replaceState(null, null, '/sync') 31 | 32 | ko.components.register('sync', { 33 | viewModel: class { 34 | constructor(ctx) { 35 | ctx.addBeforeNavigateCallback(() => !block) 36 | } 37 | } 38 | }) 39 | 40 | ko.components.register('async-callback', { 41 | viewModel: class { 42 | constructor(ctx) { 43 | ctx.addBeforeNavigateCallback((done) => done(!block)) 44 | } 45 | } 46 | }) 47 | 48 | ko.components.register('async-promise', { 49 | viewModel: class { 50 | constructor(ctx) { 51 | ctx.addBeforeNavigateCallback(() => Promise.resolve(!block)) 52 | } 53 | } 54 | }) 55 | 56 | let hit = false 57 | 58 | ko.components.register('nested', { 59 | template: '', 60 | viewModel: class { 61 | constructor(ctx) { 62 | ctx.addBeforeNavigateCallback(() => { 63 | t.ok(hit, 'callbacks are called sequentially from bottom => top') 64 | }) 65 | } 66 | } 67 | }) 68 | 69 | ko.components.register('nested-child', { 70 | viewModel: class { 71 | constructor(ctx) { 72 | ctx.addBeforeNavigateCallback((done) => { 73 | setTimeout(() => { 74 | hit = true 75 | done() 76 | }, 200) 77 | }) 78 | } 79 | } 80 | }) 81 | 82 | await Router.update('/sync') 83 | block = true 84 | t.notOk(await Router.update('/'), 'returning false should prevent navigation') 85 | block = false 86 | t.ok(await Router.update('/'), 'returning !false should not prevent navigation') 87 | 88 | await Router.update('/async-callback') 89 | block = true 90 | t.notOk(await Router.update('/'), 'calling the callback with false should prevent navigation') 91 | block = false 92 | t.ok(await Router.update('/'), 'calling the callback with !false should not prevent navigation') 93 | 94 | await Router.update('/async-promise') 95 | block = true 96 | t.notOk(await Router.update('/'), 'returning a promise that resolves false should prevent navigation') 97 | block = false 98 | t.ok(await Router.update('/'), 'returning a promise that resolves !false should prvent navigation') 99 | 100 | await Router.update('/nested') 101 | await Router.update('/') 102 | } 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /test/bindings/active-path.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import $ from 'jquery' 3 | 4 | import { Router } from '../../' 5 | 6 | ko.components.register('bindings-active-path', { 7 | template: ` 8 | 9 | 10 | 11 | 12 | 13 | 14 | `, 15 | viewModel: class BindingTest { 16 | constructor({ t, done }) { 17 | history.replaceState(null, null, '/a/a') 18 | 19 | Router.useRoutes({ 20 | '/a': [ 21 | 'a', 22 | { 23 | '/a': 'a-inner' 24 | } 25 | ], 26 | '/b': 'b' 27 | }) 28 | 29 | ko.components.register('a', { 30 | synchronous: true, 31 | viewModel: class { 32 | constructor(ctx) { 33 | ctx.$child.router.initialized.then(() => { 34 | t.ok($('#custom-class').hasClass('custom-active-class'), 'should apply custom active class when used with pathActiveClass binding') 35 | t.ok($('#outer-relative-a').hasClass('active-path'), 'should apply active class on elements outside routers') 36 | t.ok($('#inner-relative').hasClass('active-path'), 'should apply active class on relative paths inside routers') 37 | t.ok($('#nested-relative').hasClass('active-path'), 'should apply active class on nested relative paths') 38 | t.ok($('#outer-deep').hasClass('active-path'), 'should apply active class on deep paths') 39 | 40 | Router.update('/b') 41 | }) 42 | } 43 | }, 44 | template: ` 45 | 46 | 47 | 48 | ` 49 | }) 50 | 51 | ko.components.register('a-inner', { 52 | synchronous: true, 53 | template: ` 54 | 55 | 56 | 57 | ` 58 | }) 59 | 60 | ko.components.register('b', { 61 | viewModel: class { 62 | constructor() { 63 | done() 64 | } 65 | } 66 | }) 67 | } 68 | } 69 | }) 70 | -------------------------------------------------------------------------------- /test/bindings/index.js: -------------------------------------------------------------------------------- 1 | import './path' 2 | import './active-path' -------------------------------------------------------------------------------- /test/bindings/path.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import $ from 'jquery' 3 | 4 | import { Router } from '../../' 5 | 6 | ko.components.register('bindings-path', { 7 | template: ` 8 | 9 | 10 | 11 | 12 | 13 | 14 | `, 15 | viewModel: class BindingTest { 16 | constructor({ t, done }) { 17 | history.replaceState(null, null, '/a/a') 18 | 19 | Router.useRoutes({ 20 | '/a': [ 21 | 'a', 22 | { 23 | '/a': 'a-inner' 24 | } 25 | ], 26 | '/b': 'b' 27 | }) 28 | 29 | ko.components.register('a', { 30 | synchronous: true, 31 | viewModel: class { 32 | constructor(ctx) { 33 | ctx.$child.router.initialized.then(() => { 34 | t.equals('/a/a', $('#outer-relative-a').attr('href')) 35 | 36 | t.equals('/a/a', $('#inner-relative').attr('href')) 37 | t.equals('/a', $('#inner-absolute').attr('href')) 38 | 39 | t.equals('/a/a', $('#nested-relative').attr('href')) 40 | t.equals('/a', $('#nested-relative-up').attr('href')) 41 | t.equals('/a', $('#nested-absolute').attr('href')) 42 | 43 | t.ok($('#custom-class').hasClass('custom-active-class'), 'should apply custom active class when used with pathActiveClass binding') 44 | t.ok($('#outer-relative-a').hasClass('active-path'), 'should apply active class on elements outside routers') 45 | t.ok($('#inner-relative').hasClass('active-path'), 'should apply active class on relative paths inside routers') 46 | t.ok($('#nested-relative').hasClass('active-path'), 'should apply active class on nested relative paths') 47 | t.ok($('#outer-deep').hasClass('active-path'), 'should apply active class on deep paths') 48 | 49 | Router.update('/b') 50 | }) 51 | } 52 | }, 53 | template: ` 54 | 55 | 56 | 57 | ` 58 | }) 59 | 60 | ko.components.register('a-inner', { 61 | synchronous: true, 62 | template: ` 63 | 64 | 65 | 66 | ` 67 | }) 68 | 69 | ko.components.register('b', { 70 | viewModel: class { 71 | constructor() { 72 | done() 73 | } 74 | } 75 | }) 76 | } 77 | } 78 | }) 79 | -------------------------------------------------------------------------------- /test/force-update.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | import { Router } from '../' 4 | 5 | ko.components.register('force-update', { 6 | template: '', 7 | viewModel: class ForceUpdate { 8 | constructor({ t, done }) { 9 | let count = 0 10 | 11 | Router.useRoutes({ 12 | '/': 'foo' 13 | }) 14 | 15 | history.pushState(null, null, '/') 16 | 17 | ko.components.register('foo', { 18 | viewModel: class { 19 | constructor(ctx) { 20 | if (++count === 1) { 21 | ctx.router.update('/', { force: true }) 22 | } else { 23 | t.pass('can force same-route update') 24 | done() 25 | } 26 | } 27 | } 28 | }) 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /test/hashbang.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import $ from 'jquery' 3 | 4 | import { Router } from '../' 5 | 6 | ko.components.register('hashbang', { 7 | template: ` 8 | 9 | 10 | `, 11 | viewModel: class Hashbang { 12 | constructor({ t, done }) { 13 | Router.setConfig({ 14 | hashbang: true, 15 | base: '/base' 16 | }) 17 | 18 | Router.useRoutes({ 19 | '/foo': { 20 | '/foo': 'foo' 21 | }, 22 | '/bar': { 23 | '/bar': 'bar' 24 | } 25 | }) 26 | 27 | history.pushState(null, null, '/base/#!/foo/foo') 28 | 29 | ko.components.register('foo', { 30 | viewModel: class { 31 | constructor(ctx) { 32 | t.pass('initializes with hashbang') 33 | t.true(location.href.indexOf('/base/#!/foo/foo') > -1, 'uses hash in url on init') 34 | 35 | ctx.router.initialized.then(() => setTimeout(() => { // dirty hack for FF/TravisCI 36 | t.equals($('#foo-link').attr('href'), '/base/#!/foo/foo', 'sets href correctly in path binding') 37 | Router.update('/bar/bar') 38 | })) 39 | } 40 | } 41 | }) 42 | 43 | ko.components.register('bar', { 44 | viewModel: class { 45 | constructor() { 46 | t.pass('navigates correctly with hashbang') 47 | t.true(location.href.indexOf('/base/#!/bar/bar') > -1, 'uses hash in url on update') 48 | 49 | done() 50 | } 51 | } 52 | }) 53 | } 54 | 55 | dispose() { 56 | Router.setConfig({ 57 | hashbang: false, 58 | base: '' 59 | }) 60 | } 61 | } 62 | }) 63 | -------------------------------------------------------------------------------- /test/helpers/ko-overwrite-component-registration.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | const _register = ko.components.register 4 | 5 | ko.components.register = (name, { 6 | template = '
', 7 | viewModel = class { } 8 | }) => { 9 | if (ko.components.isRegistered(name)) { 10 | ko.components.unregister(name) 11 | ko.components.clearCachedDefinition(name) 12 | } 13 | return _register(name, { template, viewModel }) 14 | } 15 | -------------------------------------------------------------------------------- /test/history.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | import { Router } from '../' 4 | 5 | ko.components.register('history', { 6 | template: '', 7 | viewModel: class History { 8 | constructor({ t, done }) { 9 | Router.useRoutes({ 10 | '/a': 'a', // init 11 | '/b': 'b', // update(path) 12 | '/c': 'c', // update(path, {}) 13 | '/d': 'd', // update(path, false) 14 | '/e': 'e' // update(path, { push: false }) 15 | }) 16 | 17 | history.pushState(null, null, '/a') 18 | 19 | const begin = history.length 20 | 21 | if (begin > 48) { 22 | ko.components.register('a', {}) 23 | t.skip('Unable to test history.length b/c history.length is too long') 24 | done() 25 | return 26 | } 27 | 28 | ko.components.register('a', { 29 | viewModel: class { 30 | constructor(ctx) { 31 | t.equals(history.length, begin, 'does not add history entry on initialization') 32 | ctx.router.update('/b') 33 | } 34 | } 35 | }) 36 | 37 | ko.components.register('b', { 38 | viewModel: class { 39 | constructor(ctx) { 40 | t.equals(history.length, begin + 1, 'adds history entry when no second argument') 41 | ctx.router.update('/c', {}) 42 | } 43 | } 44 | }) 45 | 46 | ko.components.register('c', { 47 | viewModel: class { 48 | constructor(ctx) { 49 | t.equals(history.length, begin + 2, 'adds history entry when second argument is object and has undefined push property') 50 | ctx.router.update('/d', false) 51 | } 52 | } 53 | }) 54 | 55 | ko.components.register('d', { 56 | viewModel: class { 57 | constructor(ctx) { 58 | t.equals(history.length, begin + 2, 'does not add history entry with false second argument') 59 | ctx.router.update('/e', { push: false }) 60 | } 61 | } 62 | }) 63 | 64 | ko.components.register('e', { 65 | viewModel: class { 66 | constructor() { 67 | t.equals(history.length, begin + 2, 'does not add history entry when second argument is objcet and push property is false') 68 | done() 69 | } 70 | } 71 | }) 72 | } 73 | } 74 | }) 75 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import $ from 'jquery' 3 | import tape from 'tape' 4 | 5 | import './helpers/ko-overwrite-component-registration' 6 | 7 | import './anchor' 8 | import './basepath' 9 | import './bindings' 10 | import './routing' 11 | import './hashbang' 12 | import './history' 13 | import './force-update' 14 | import './with' 15 | import './middleware' 16 | import './queue' 17 | import './redirect' 18 | import './before-navigate-callbacks' 19 | import './plugins' 20 | 21 | const tests = [ 22 | 'routing', 23 | 'basepath', 24 | 'hashbang', 25 | 'history', 26 | 'force-update', 27 | 'with', 28 | 'anchor', 29 | 'bindings-path', 30 | 'bindings-active-path', 31 | 'middleware', 32 | 'queue', 33 | 'redirect', 34 | 'before-navigate-callbacks', 35 | 'plugins' 36 | ] 37 | 38 | class TestRunner { 39 | constructor() { 40 | $('body').append(` 41 |
42 |
43 |
47 |
48 | `) 49 | this.test = ko.observable(null) 50 | this.runTests() 51 | } 52 | 53 | async runTests() { 54 | for (const test of tests) { 55 | await this.runTest(test) 56 | } 57 | } 58 | 59 | async runTest(test) { 60 | history.pushState(null, null, '/') 61 | 62 | return await new Promise((resolve) => 63 | tape(test, (t) => { 64 | this.t = t 65 | this.done = () => { 66 | t.end() 67 | resolve() 68 | } 69 | this.test(test) 70 | })) 71 | } 72 | } 73 | 74 | ko.applyBindings(new TestRunner()) 75 | -------------------------------------------------------------------------------- /test/middleware.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | import { Router } from '../' 4 | 5 | ko.components.register('middleware', { 6 | template: '', 7 | viewModel: class MiddlewareTest { 8 | constructor({ t, done }) { 9 | 10 | Router.use(function * (ctx) { 11 | ctx.beforeRenderGlobalMiddlewareHit = true 12 | yield 13 | ctx.afterRenderGlobalMiddlewareHit = true 14 | yield 15 | ctx.beforeDisposeGlobalMiddlewareHit = true 16 | yield 17 | ctx.afterDisposeGlobalMiddlewareHit = true 18 | }) 19 | 20 | history.replaceState(null, null, '/sync') 21 | 22 | Router.useRoutes({ 23 | '/sync': [ 24 | (ctx) => { 25 | t.ok(ctx, 'middleware is ran with ctx as first argument') 26 | }, 27 | () => { 28 | Router.update('/async') 29 | } 30 | ], 31 | 32 | '/async': [ 33 | (ctx, _done) => { 34 | setTimeout(() => { 35 | ctx.waitOver = true 36 | _done() 37 | }, 200) 38 | }, 39 | (ctx) => { 40 | t.ok(ctx.waitOver, 'async middleware works with done callback') 41 | ctx.waitOver = false 42 | return new Promise((resolve) => { 43 | setTimeout(() => { 44 | ctx.waitOver = true 45 | resolve() 46 | }) 47 | }) 48 | }, 49 | (ctx) => { 50 | t.ok(ctx.waitOver, 'async middleware works with promise') 51 | ctx.waitOver = false 52 | 53 | Router.update('/generator') 54 | } 55 | ], 56 | 57 | '/generator': [ 58 | function * (ctx) { 59 | t.pass('generator middleware is called') 60 | 61 | t.ok(ctx.beforeRenderGlobalMiddlewareHit, 'global middleware before render middleware is executed') 62 | t.ok(ctx.beforeRenderGlobalMiddlewareHit, 'route before before render middleware is called after global before render middleware') 63 | 64 | yield new Promise((resolve) => { 65 | setTimeout(() => { 66 | ctx.waitOver = true 67 | resolve() 68 | }, 200) 69 | }) 70 | 71 | t.ok(ctx.afterRenderGlobalMiddlewareHit, 'route after render middleware is called after global after render middleware') 72 | 73 | Router.update('/object') 74 | 75 | yield 76 | 77 | t.ok(ctx.beforeNavigateHit, 'before dispose middleware is called after before navigate callbacks') 78 | t.notOk(ctx.beforeDisposeGlobalMiddlewareHit, 'route before dispose middleware is called before global before dispose middleware') 79 | 80 | yield 81 | 82 | t.notOk(ctx.afterDisposeGlobalMiddlewareHit, 'route after dispose middleware is called before global after dispose middleware') 83 | }, 84 | (ctx) => { 85 | t.ok(ctx.waitOver, 'generator middleware works with yield-ed promise') 86 | }, 87 | 'generator' 88 | ], 89 | 90 | '/object': [ 91 | (ctx) => ({ 92 | beforeRender() { 93 | t.pass('object middleware is called') 94 | 95 | t.ok(ctx.beforeRenderGlobalMiddlewareHit, 'global middleware before render middleware is executed') 96 | t.ok(ctx.beforeRenderGlobalMiddlewareHit, 'route before before render middleware is called after global before render middleware') 97 | 98 | return new Promise((resolve) => { 99 | setTimeout(() => { 100 | ctx.promiseWaitOver = true 101 | resolve() 102 | }, 200) 103 | }) 104 | }, 105 | afterRender(done) { 106 | t.ok(ctx.afterRenderGlobalMiddlewareHit, 'route after render middleware is called after global after render middleware') 107 | 108 | setTimeout(() => { 109 | ctx.callbackWaitOver = true 110 | done() 111 | }, 200) 112 | }, 113 | beforeDispose() { 114 | t.ok(ctx.beforeNavigateHit, 'before dispose middleware is called after before navigate callbacks') 115 | t.notOk(ctx.beforeDisposeGlobalMiddlewareHit, 'route before dispose middleware is called before global before dispose middleware') 116 | }, 117 | afterDispose() { 118 | t.notOk(ctx.afterDisposeGlobalMiddlewareHit, 'route after dispose middleware is called before global after dispose middleware') 119 | } 120 | }), 121 | (ctx) => ({ 122 | beforeRender() { 123 | t.ok(ctx.promiseWaitOver, 'object middleware works with returned promise') 124 | }, 125 | afterRender() { 126 | t.ok(ctx.callbackWaitOver, 'object middleware works with callback') 127 | done() 128 | } 129 | }), 130 | 'object' 131 | ] 132 | }) 133 | 134 | ko.components.register('generator', { 135 | viewModel: class { 136 | constructor(ctx) { 137 | ctx.addBeforeNavigateCallback(() => (ctx.beforeNavigateHit = true)) 138 | } 139 | } 140 | }) 141 | 142 | ko.components.register('object', { 143 | viewModel: class { 144 | constructor(ctx) { 145 | ctx.addBeforeNavigateCallback(() => (ctx.beforeNavigateHit = true)) 146 | } 147 | } 148 | }) 149 | } 150 | 151 | dispose() { 152 | Router.middleware = [] 153 | } 154 | } 155 | }) 156 | -------------------------------------------------------------------------------- /test/plugins.js: -------------------------------------------------------------------------------- 1 | import { isPlainObject, merge } from 'lodash-es' 2 | import ko from 'knockout' 3 | 4 | import { Router } from '../' 5 | 6 | ko.components.register('plugins', { 7 | template: '', 8 | viewModel: class PluginTest { 9 | constructor({ t, done }) { 10 | history.replaceState(null, null, '/component') 11 | 12 | Router.usePlugin( 13 | (route) => { 14 | if (route.component) { 15 | return route.component 16 | } 17 | }, 18 | (route) => { 19 | if (route.data) { 20 | return isPlainObject(route.data) 21 | ? Object.entries(route.data).map(([k, v]) => 22 | (ctx) => v.then((_v) => merge(ctx, { data: { [k]: _v } }))) 23 | : (ctx) => route.data.then((v) => (ctx.data = v)) 24 | } 25 | } 26 | ) 27 | 28 | Router.useRoutes({ 29 | '/component': { 30 | component: 'component' 31 | }, 32 | // eslint-disable-next-line formatting/newline-object-in-array 33 | '/data': ['data', { 34 | data: Promise.resolve(true) 35 | }], 36 | // eslint-disable-next-line formatting/newline-object-in-array 37 | '/data-multi': ['data-multi', { 38 | data: { 39 | true: Promise.resolve(true), 40 | false: Promise.resolve(false) 41 | } 42 | }], 43 | '/composed': { 44 | component: 'composed', 45 | data: Promise.resolve(true) 46 | }, 47 | }) 48 | 49 | ko.components.register('component', { 50 | viewModel: class { 51 | constructor() { 52 | t.pass('plugin works with returned string') 53 | Router.update('/data') 54 | } 55 | } 56 | }) 57 | 58 | ko.components.register('data', { 59 | viewModel: class { 60 | constructor(ctx) { 61 | t.equals(true, ctx.data, 'plugin works with returned middleware func') 62 | Router.update('/data-multi') 63 | } 64 | } 65 | }) 66 | 67 | ko.components.register('data-multi', { 68 | viewModel: class { 69 | constructor(ctx) { 70 | t.deepEquals({ true: true, false: false }, ctx.data, 'plugin works with returned array of middleware funcs') 71 | Router.update('/composed') 72 | } 73 | } 74 | }) 75 | 76 | ko.components.register('composed', { 77 | viewModel: class { 78 | constructor(ctx) { 79 | t.equals(true, ctx.data, 'plugins can be composed') 80 | done() 81 | } 82 | } 83 | }) 84 | } 85 | 86 | dispose() { 87 | Router.plugins = [] 88 | } 89 | } 90 | }) 91 | -------------------------------------------------------------------------------- /test/queue.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | import { Router } from '../' 4 | 5 | ko.components.register('queue', { 6 | template: '', 7 | viewModel: class QueueTest { 8 | constructor({ t, done }) { 9 | let queuedPromiseAResolved = false 10 | let queuedPromiseBResolved = false 11 | 12 | Router.useRoutes({ 13 | '/': [ 14 | (ctx) => 15 | ctx.queue(new Promise((resolve) => { 16 | setTimeout(() => { 17 | queuedPromiseAResolved = true 18 | resolve() 19 | }, 1000) 20 | })), 21 | () => { 22 | t.notOk(queuedPromiseAResolved, 'queued promises let middleware continue') 23 | }, 24 | { 25 | '/': ['foo', 26 | (ctx) => 27 | ctx.queue(new Promise((resolve) => { 28 | setTimeout(() => { 29 | queuedPromiseBResolved = true 30 | resolve() 31 | }, 1000) 32 | })), 33 | () => { 34 | t.notOk(queuedPromiseAResolved, 'queued promises in parent router does not prevent child middleware from executing') 35 | } 36 | ] 37 | } 38 | ] 39 | }) 40 | 41 | ko.components.register('foo', { 42 | viewModel: class { 43 | constructor() { 44 | t.ok(queuedPromiseAResolved, 'queued promise in parent router resolves before component render') 45 | t.ok(queuedPromiseBResolved, 'queued promise in child router resolves before component render') 46 | done() 47 | } 48 | } 49 | }) 50 | } 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /test/redirect.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | import { Router } from '../' 4 | 5 | ko.components.register('redirect', { 6 | template: '', 7 | viewModel: class RedirectTest { 8 | constructor({ t, done }) { 9 | const fooPre = {} 10 | const barPre = {} 11 | 12 | history.pushState(null, null, '/notfoo') 13 | 14 | Router.use( 15 | () => ({ 16 | beforeRender() { 17 | fooPre.beforeRender = true 18 | }, 19 | afterRender() { 20 | fooPre.afterRender = true 21 | }, 22 | beforeDispose() { 23 | fooPre.beforeDispose = true 24 | }, 25 | afterDispose() { 26 | fooPre.afterDispose = true 27 | } 28 | }), 29 | (ctx) => { 30 | if (ctx.pathname === '/notfoo') { 31 | ctx.redirect('/foo') 32 | } 33 | }, 34 | (ctx) => ({ 35 | beforeRender() { 36 | if (ctx.pathname === '/notfoo') { 37 | t.fail('beforeRender middleware after redirect in global middleware should not be executed') 38 | } 39 | }, 40 | afterRender() { 41 | if (ctx.pathname === '/notfoo') { 42 | t.fail('afterRender middleware after redirect in global middleware should not be executed') 43 | } 44 | }, 45 | beforeDispose() { 46 | if (ctx.pathname === '/notfoo') { 47 | t.fail('beforeDispose middleware after redirect in global middleware should not be executed') 48 | } 49 | }, 50 | afterDispose() { 51 | if (ctx.pathname === '/notfoo') { 52 | t.fail('afterDispose middleware after redirect in global middleware should not be executed') 53 | } 54 | } 55 | })) 56 | 57 | Router.useRoutes({ 58 | '/notfoo': 'notfoo', 59 | '/foo': 'foo', 60 | '/notbar': [ 61 | 'notbar', 62 | () => ({ 63 | beforeRender() { 64 | barPre.beforeRender = true 65 | }, 66 | afterRender() { 67 | barPre.afterRender = true 68 | }, 69 | beforeDispose() { 70 | barPre.beforeDispose = true 71 | }, 72 | afterDispose() { 73 | barPre.afterDispose = true 74 | } 75 | }), 76 | (ctx) => { 77 | ctx.redirect('/bar') 78 | }, 79 | () => ({ 80 | beforeRender() { 81 | t.fail('beforeRender middleware after redirect in route middleware should not be executed') 82 | }, 83 | afterRender() { 84 | t.fail('afterRender middleware after redirect in route middleware should not be executed') 85 | }, 86 | beforeDispose() { 87 | t.fail('beforeDispose middleware after redirect in route middleware should not be executed') 88 | }, 89 | afterDispose() { 90 | t.fail('afterDispose middleware after redirect in route middleware should not be executed') 91 | } 92 | }) 93 | ], 94 | '/bar': 'bar' 95 | }) 96 | 97 | ko.components.register('notfoo', { 98 | viewModel: class { 99 | constructor() { 100 | t.fail('global redirect should not have intermediate render') 101 | } 102 | } 103 | }) 104 | 105 | ko.components.register('notbar', { 106 | viewModel: class { 107 | constructor() { 108 | t.fail('route redirect should not have intermediate render') 109 | } 110 | } 111 | }) 112 | 113 | ko.components.register('foo', { 114 | synchronous: true, 115 | viewModel: class { 116 | constructor() { 117 | t.true(fooPre.beforeRender, 'pre global redirect beforeRender is ran') 118 | t.true(fooPre.afterRender, 'pre global redirect afterRender is ran') 119 | t.true(fooPre.beforeDispose, 'pre global redirect beforeDispose is ran') 120 | t.true(fooPre.afterDispose, 'pre global redirect afterDispose is ran') 121 | 122 | Router.update('/notbar') 123 | } 124 | } 125 | }) 126 | 127 | ko.components.register('bar', { 128 | synchronous: true, 129 | viewModel: class { 130 | constructor() { 131 | t.true(barPre.beforeRender, 'pre route redirect beforeRender is ran') 132 | t.true(barPre.afterRender, 'pre route redirect afterRender is ran') 133 | t.true(barPre.beforeDispose, 'pre route redirect beforeDispose is ran') 134 | t.true(barPre.afterDispose, 'pre route redirect afterDispose is ran') 135 | 136 | done() 137 | } 138 | } 139 | }) 140 | } 141 | } 142 | }) 143 | -------------------------------------------------------------------------------- /test/routing/ambiguous.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | ko.components.register('ambiguous', { 4 | template: '', 5 | viewModel: class AmbiguousRoutingTest { 6 | constructor({ t, done }) { 7 | ko.components.register('wrong', { 8 | viewModel: class { 9 | constructor() { 10 | t.fail('fails on ambiguous routes w/ nested shorthand') 11 | done() 12 | } 13 | } 14 | }) 15 | 16 | ko.components.register('right', { 17 | viewModel: class { 18 | constructor() { 19 | t.pass('figures out ambiguous routes w/ nested shorthand') 20 | done() 21 | } 22 | } 23 | }) 24 | } 25 | } 26 | }) 27 | 28 | export const path = '/ambiguous/a/b/c' 29 | 30 | export const routes = { 31 | '/ambiguous': ['ambiguous', 32 | { 33 | '/': { 34 | '/a': { 35 | '/b': 'wrong' 36 | } 37 | }, 38 | '/a': { 39 | '/b': { 40 | '/c': 'right' 41 | } 42 | } 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /test/routing/basic.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | ko.components.register('basic', { 4 | viewModel: class BasicRoutingTest { 5 | constructor({ t, done }) { 6 | t.pass('navigates to basic route') 7 | done() 8 | } 9 | } 10 | }) 11 | 12 | export const path = '/basic' 13 | 14 | export const routes = { 15 | '/basic': 'basic' 16 | } 17 | -------------------------------------------------------------------------------- /test/routing/index.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | import { extend, map } from 'lodash-es' 3 | 4 | import { Router } from '../../' 5 | 6 | import * as init from './init' 7 | import * as basic from './basic' 8 | import * as params from './params' 9 | import * as nested from './nested' 10 | import * as similar from './similar' 11 | import * as ambiguous from './ambiguous' 12 | 13 | const tests = [ 14 | basic, 15 | params, 16 | nested, 17 | similar, 18 | ambiguous 19 | ] 20 | 21 | const paths = map(tests, 'path') 22 | 23 | ko.components.register('routing', { 24 | template: '', 25 | viewModel: class RoutingTestSuite { 26 | constructor({ t, done }) { 27 | Router.useRoutes(init.routes) 28 | history.pushState(null, null, init.path) 29 | Router.useRoutes(extend({}, ...map(tests, 'routes'))) 30 | 31 | let resolve 32 | new Promise((_resolve) => (resolve = _resolve)) 33 | .then(() => { 34 | this.runTests(t).then(done) 35 | }) 36 | 37 | this.t = t 38 | this.done = () => resolve() 39 | } 40 | 41 | async runTests(t) { 42 | for (const path of paths) { 43 | await new Promise((resolve) => { 44 | Router.update(path, { with: { t, done: resolve } }) 45 | }) 46 | } 47 | } 48 | } 49 | }) 50 | -------------------------------------------------------------------------------- /test/routing/init.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | ko.components.register('init', { 4 | viewModel: class RoutingInitializationTest { 5 | constructor({ t, done }) { 6 | t.pass('initializes') 7 | done() 8 | } 9 | } 10 | }) 11 | 12 | export const path = '/init' 13 | 14 | export const routes = { 15 | '/init': 'init' 16 | } 17 | -------------------------------------------------------------------------------- /test/routing/nested.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | import { Router } from '../../' 4 | 5 | ko.components.register('nested', { 6 | template: '', 7 | viewModel: class NestedRoutingTest { 8 | constructor(ctx) { 9 | const { t, done } = ctx 10 | 11 | t.pass('navigates to route with children') 12 | t.pass('uses specifed component with routes') 13 | 14 | const hLen = history.length 15 | 16 | ko.components.register('root', { 17 | viewModel: class { 18 | constructor() { 19 | t.equals(Router.head, Router.get(0), 'Router.head is top-most router') 20 | t.pass('initializes nested route') 21 | t.equals(hLen, history.length, 'child route does not add history entry') 22 | Router.update('/nested/a') 23 | } 24 | } 25 | }) 26 | 27 | ko.components.register('a', { 28 | viewModel: class { 29 | constructor(ctx) { 30 | t.pass('navigates to new child route from parent router') 31 | ctx.router.update('/b') 32 | } 33 | } 34 | }) 35 | 36 | ko.components.register('b', { 37 | viewModel: class { 38 | constructor() { 39 | t.pass('navigates to new child route from child router') 40 | Router.update('/nested/c') 41 | } 42 | } 43 | }) 44 | 45 | let parent 46 | 47 | ko.components.register('c-pre', { 48 | template: '', 49 | viewModel: class { 50 | constructor(ctx) { 51 | parent = ctx 52 | } 53 | } 54 | }) 55 | 56 | ko.components.register('c', { 57 | viewModel: class { 58 | constructor(ctx) { 59 | t.pass('works with implied router component (no specified component)') 60 | 61 | t.equals(ctx.$root.$child.router, Router.get(1), 'Router.get(n) works') 62 | 63 | t.equals(ctx.$root, Router.head.ctx, 'ctx.$root is Router.head.ctx') 64 | t.equals(Router.head.ctx.$parent, undefined, 'root ctx.$parent is undefined') 65 | 66 | t.equals(parent.$child, ctx, 'ctx.$child is child ctx') 67 | t.equals(ctx.$parent, parent, 'ctx.$parent is parent ctx') 68 | 69 | t.equals(ctx.$parents[0], parent, 'ctx.$parents is array of parents, 0=$parent') 70 | t.equals(ctx.$parents[1], parent.$parent, 'ctx.$parents is array of parents, 1=$parent.$parent') 71 | 72 | t.equals(parent.$children[0], ctx, 'ctx.$children is array of child ctxs') 73 | 74 | done() 75 | } 76 | } 77 | }) 78 | } 79 | } 80 | }) 81 | 82 | export const path = '/nested' 83 | 84 | export const routes = { 85 | '/nested': ['nested', 86 | { 87 | '/': 'root', 88 | '/a': 'a', 89 | '/b': 'b', 90 | '/c': [ 91 | 'c-pre', 92 | { 93 | '/': 'c' // https://www.youtube.com/watch?v=5l-PjIqPOBw 94 | } 95 | ] 96 | } 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /test/routing/params.js: -------------------------------------------------------------------------------- 1 | /* [path-to-regexp](https://github.com/pillarjs/path-to-regexp) is well tested, 2 | * so there isn't too much room for error in this as long as the params are being 3 | * attached to context and the route is working 4 | **/ 5 | 6 | import ko from 'knockout' 7 | 8 | ko.components.register('params', { 9 | viewModel: class ParamsTest { 10 | constructor({ t, done, params }) { 11 | t.equal('foo', params.foo, 'parses param to ctx.params') 12 | done() 13 | } 14 | } 15 | }) 16 | 17 | export const path = '/params/foo' 18 | 19 | export const routes = { 20 | '/params/:foo': 'params' 21 | } 22 | -------------------------------------------------------------------------------- /test/routing/similar.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | ko.components.register('similar', { 4 | viewModel: class SimilarRoutesTest { 5 | constructor({ t, done, params }) { 6 | t.notOk(params.foo, 'should use most restrictive route') 7 | done() 8 | } 9 | } 10 | }) 11 | 12 | export const path = '/similar/foo/bar' 13 | 14 | export const routes = { 15 | '/similar/:foo/:bar': 'similar', 16 | '/similar/foo/:bar': 'similar' 17 | } 18 | -------------------------------------------------------------------------------- /test/with.js: -------------------------------------------------------------------------------- 1 | import ko from 'knockout' 2 | 3 | import { Router } from '../' 4 | 5 | ko.components.register('with', { 6 | template: '', 7 | viewModel: class With { 8 | constructor({ t, done }) { 9 | Router.useRoutes({ 10 | '/a': 'a', 11 | '/b': 'b' 12 | }) 13 | 14 | history.pushState(null, null, '/a') 15 | 16 | ko.components.register('a', { 17 | viewModel: class { 18 | constructor(ctx) { 19 | ctx.router.update('/b', { with: { foo: 'foo' } }) 20 | } 21 | } 22 | }) 23 | 24 | ko.components.register('b', { 25 | viewModel: class { 26 | constructor(ctx) { 27 | t.equals(ctx.foo, 'foo', 'can pass data using with') 28 | done() 29 | } 30 | } 31 | }) 32 | } 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "importHelpers": true, 5 | "moduleResolution": "node", 6 | "target": "es5", 7 | "module": "es2015", 8 | "declaration": true, 9 | "rootDir": "./src", 10 | "baseUrl": "./src", 11 | "sourceMap": true, 12 | "inlineSources": true, 13 | "outDir": "./dist", 14 | "lib": [ 15 | "dom", 16 | "es5", 17 | "es2015.core", 18 | "es2015.iterable", 19 | "es2015.promise", 20 | "es2017.object", 21 | "esnext.asynciterable" 22 | ], 23 | "downlevelIteration": true, 24 | "noImplicitAny": true, 25 | "noUnusedLocals": true 26 | }, 27 | "include": [ 28 | "src/**/*" 29 | ], 30 | "exclude": [ 31 | "dist", 32 | "test", 33 | "examples", 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended" 4 | ], 5 | "rules": { 6 | "array-type": [true, "array"], 7 | "curly": true, 8 | "interface-over-type-literal": false, 9 | "quotemark": [true, "single"], 10 | "member-ordering": [true, { "order": "instance-sandwich" }], 11 | "object-literal-sort-keys": false, 12 | "ordered-imports": [false], 13 | "semicolon": [true, "never"], 14 | "trailing-comma": [true, "never"], 15 | "variable-name": [true, "allow-leading-underscore"] 16 | } 17 | } --------------------------------------------------------------------------------