├── .gitignore ├── gulpfile.js ├── package.json ├── README.md ├── src └── driver.js └── dist └── driver.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var babel = require('gulp-babel'); 3 | 4 | gulp.task('default', function () { 5 | return gulp.src('src/driver.js') 6 | .pipe(babel()) 7 | .pipe(gulp.dest('dist')); 8 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycle-router5", 3 | "version": "0.2.0", 4 | "description": "A router driver for Cycle.js, based on Router5", 5 | "main": "dist/driver.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/axefrog/cycle-router5" 12 | }, 13 | "keywords": [ 14 | "cycle", 15 | "cycle.js", 16 | "cyclejs", 17 | "router", 18 | "router5", 19 | "js" 20 | ], 21 | "author": "Nathan Ridley", 22 | "license": "BSD", 23 | "bugs": { 24 | "url": "https://github.com/axefrog/cycle-router5/issues" 25 | }, 26 | "homepage": "https://github.com/axefrog/cycle-router5", 27 | "devDependencies": { 28 | "babel-core": "^5.8.22", 29 | "gulp": "^3.9.0", 30 | "gulp-babel": "^5.2.0" 31 | }, 32 | "dependencies": { 33 | "router5": "^0.5.0", 34 | "rx": "^3.1.0" 35 | }, 36 | "peerDependencies": { 37 | "@cycle/core": "^3.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cycle Router5 Driver 2 | 3 | A source/sink router driver for [Cycle.js](http://cycle.js.org), based on [router5](http://router5.github.io/). 4 | 5 | See the router5 documentation to learn how router5 works. See the Cycle.js documentation to learn how Cycle and its driver system works. 6 | 7 | ## Installation 8 | 9 | ```text 10 | npm install --save cycle-router5 11 | ``` 12 | 13 | ## Usage: 14 | 15 | ### Construction 16 | 17 | Create a router driver like this: 18 | 19 | ```js 20 | var drivers = { 21 | DOM: makeDOMDriver(...), 22 | router: makeRouterDriver(...) 23 | } 24 | ``` 25 | 26 | You can pass the same arguments to `makeRouterDriver` as you would to the [router5 constructor](http://router5.github.io/docs/api-reference.html). 27 | 28 | ### Click Handler 29 | 30 | I have adapted some of the code from [Visionmedia's page.js router](https://github.com/visionmedia/page.js) to handle automatic intercepting of link clicks. When a link is clicked, a number of verifications are initially performed to validate that the link matches a registered route. If so, the action is cancelled and router5 is called upon to enact the route transition. If no route is matched, the click resolves normally. 31 | 32 | If you wish to handle this behaviour, you can pass the option `disableClickHandler` with the router5 options object when creating the driver, like so: 33 | 34 | ```js 35 | var drivers = { 36 | DOM: makeDOMDriver(...), 37 | router: makeRouterDriver(yourRoutes, { 38 | disableClickHandler: true, 39 | // router5 options here 40 | }) 41 | } 42 | ``` 43 | 44 | ### Making Calls 45 | 46 | In order to play nicely with Cycle.js, the router5 API methods have been divided into two groups; (1) those that cause an instant side effect and just generally return the router5 instance itself, and (2) those that either (a) return a value synchronously without side effects or (b) cause a side effect and take a callback argument. 47 | 48 | Methods in group (1) must be called via the "sink" side of the driver. That is, to make such a call, construct your call as an array of arguments, with the first argument being the name of the function to call, and the subsequent arguments being those that you'd like to pass to the function when calling it. If you're not passing any arguments, you can just emit a string instead of an array. 49 | 50 | This works just like Cycle's DOM driver works, in that you hook up an event stream to pass out to the driver, and then pass function calls out the event stream as needed. 51 | 52 | ## Rough Example (for illustration purposes only) 53 | 54 | ```js 55 | import {makeRouterDriver} from 'cycle-router5'; 56 | 57 | function intent(sources) { 58 | return { 59 | clickStart$: sources.dom.get('.start-button', 'click'), 60 | routeChange$: sources.router.addListener() 61 | }; 62 | } 63 | 64 | function model(actions) { 65 | return actions.clickStart$ 66 | .startWith(null) 67 | .map(ev => { 68 | return { 69 | started: !!ev 70 | }; 71 | }); 72 | } 73 | 74 | function view(model$) { 75 | return model$.map(model => { 76 | return h('p', [ 77 | model.started ? 'Router starting now.' : 'Router not started yet.', 78 | h('br'), 79 | h('button', { className: 'start-button' }, 'Start Router') 80 | h('br') 81 | ]); 82 | }); 83 | } 84 | 85 | function routing(actions) { 86 | return actions.clickStart$ 87 | .map(ev => 'start'); // could also be ['start'] or ['start', arg1, ...] 88 | } 89 | 90 | function main(sources) { 91 | var actions = intent(sources); 92 | return { 93 | DOM: view(model(actions)), 94 | router: routing(actions) 95 | }; 96 | } 97 | 98 | var routes = [ 99 | { name: 'home', path: '/' } 100 | ]; 101 | 102 | var routerOptions = { 103 | disableClickHandler: false // obviously you could omit this if false 104 | // other router5 constructor options go here 105 | }; 106 | 107 | var [sources, sinks] = Cycle.run(main, { 108 | DOM: makeDOMDriver('#app'), 109 | router: makeRouterDriver(routes, routerOptions) 110 | }); 111 | ``` 112 | 113 | ### API 114 | 115 | The following router5 methods can be called from the exposed driver object, i.e. via the `sources.router` object in my example. Those methods which take a callback instead have the callback wrapped up inside the returned observable stream. 116 | 117 | * start 118 | * addListener 119 | * addNodeListener 120 | * addRouteListener 121 | * navigate 122 | * matchPath 123 | * buildUrl 124 | * buildPath 125 | 126 | Router5 methods which are side-effectful (causing a state change in the router) and would normally only return the router5 object itself, or where the callback is optional, can be called by wrapping the name of the method, along with its arguments, in an array, and returning the array as a request to the driver, as per the `return` statement for the `main` function in the example above. The following methods are exposed in this way: 127 | 128 | * add 129 | * addNode 130 | * canActivate 131 | * deregisterComponent 132 | * navigate 133 | * registerComponent 134 | * setOption 135 | * start 136 | * stop 137 | 138 | Please see the [router5 documentation](http://router5.github.io/) for full API details. 139 | -------------------------------------------------------------------------------- /src/driver.js: -------------------------------------------------------------------------------- 1 | import {Router5} from 'router5'; 2 | import Rx from 'rx'; 3 | 4 | // The set of valid sink functions includes synchronous state-affecting router functions that do not require a callback 5 | // and which do not have a significant return value other than the router object itself. 6 | const validSinkFuncs = ['add','addNode','canActivate','deregisterComponent','navigate','registerComponent','setOption','start','stop']; 7 | 8 | function validateAndRemapSinkArgument(arg) { 9 | if(!arg || !arg.length) { 10 | return null; 11 | }; 12 | if(typeof arg === 'string') { 13 | arg = [arg]; 14 | } 15 | else if(!(arg instanceof Array)) { 16 | throw new Error('A Router5 sink argument should be a string or an array of arguments, starting with a function name'); 17 | } 18 | if(validSinkFuncs.indexOf(arg[0]) === -1) { 19 | throw new Error(`"${arg[0]}" is not the name of a valid sink function call for the Router5 driver`); 20 | } 21 | if(typeof arg[arg.length - 1] === 'function') { 22 | throw new Error('Router5 invocations specifying callbacks should be made using the source (responses) object'); 23 | } 24 | return arg; 25 | } 26 | 27 | function createStateChange$(router, fname, args) { 28 | return Rx.Observable.create(observer => { 29 | try { 30 | router[fname].apply(router, args.concat((toState, fromState) => { 31 | observer.onNext({ toState, fromState }); 32 | })); 33 | } 34 | catch(e) { 35 | observer.onError(e); 36 | } 37 | }); 38 | } 39 | 40 | function createDone$(router, fname, args) { 41 | return Rx.Observable.create(observer => { 42 | try { 43 | router[fname].apply(router, args.concat(() => { 44 | observer.onNext(true); 45 | observer.onCompleted(); 46 | })); 47 | } 48 | catch(e) { 49 | observer.onError(e); 50 | } 51 | }); 52 | } 53 | 54 | function makeRouterDriver(routes, options) { 55 | let router = new Router5(routes, options); 56 | 57 | if(!options || !options.disableClickHandler) { 58 | var clickEventName = (typeof document !== 'undefined') && document.ontouchstart ? 'touchstart' : 'click'; 59 | var clickHandler = makeOnClick(options.base, options.useHash, 60 | path => router.matchPath(path), 61 | ({name, params}) => router.navigate(name, params) 62 | ); 63 | document.addEventListener(clickEventName, clickHandler, false); 64 | } 65 | 66 | // The request stream allows certain synchronous [compatible] methods to be called in the form ['funcName', ...args]. 67 | return function(request$) { 68 | request$ 69 | .map(validateAndRemapSinkArgument) 70 | .subscribe( 71 | ([fname, ...args]) => { router[fname].apply(router, args); }, 72 | err => console.error(err) 73 | ); 74 | 75 | return { 76 | start: (...args) => createDone$(router, 'start', args), 77 | addListener: (...args) => createStateChange$(router, 'addListener', args), 78 | addNodeListener: (...args) => createStateChange$(router, 'addNodeListener', args), 79 | addRouteListener: (...args) => createStateChange$(router, 'addRouteListener', args), 80 | areStatesDescendants: (...args) => router.areStatesDescendants.apply(router, args), 81 | navigate: (...args) => createDone$(router, 'navigate', args), 82 | matchPath: (...args) => router.matchPath.apply(router, args), 83 | buildUrl: (...args) => router.buildUrl.apply(router, args), 84 | buildPath: (...args) => router.buildPath.apply(router, args), 85 | }; 86 | } 87 | } 88 | 89 | // The following is adapted from VisionMedia's page.js router 90 | // https://github.com/visionmedia/page.js/blob/master/index.js 91 | 92 | var makeOnClick = function(base, hashbang, match, callback) { 93 | /** 94 | * Event button. 95 | */ 96 | function which(e) { 97 | e = e || window.event; 98 | return null === e.which ? e.button : e.which; 99 | } 100 | 101 | /** 102 | * Check if `href` is the same origin. 103 | */ 104 | function sameOrigin(href) { 105 | var origin = location.protocol + '//' + location.hostname; 106 | if (location.port) origin += ':' + location.port; 107 | return (href && (0 === href.indexOf(origin))); 108 | } 109 | 110 | return function onclick(e) { 111 | 112 | if (1 !== which(e)) return; 113 | if (e.metaKey || e.ctrlKey || e.shiftKey) return; 114 | if (e.defaultPrevented) return; 115 | 116 | // ensure link 117 | var el = e.target; 118 | while (el && 'A' !== el.nodeName) el = el.parentNode; 119 | if (!el || 'A' !== el.nodeName) return; 120 | 121 | // Ignore if tag has 122 | // 1. "download" attribute 123 | // 2. rel="external" attribute 124 | if (el.hasAttribute('download') || el.getAttribute('rel') === 'external') return; 125 | 126 | // ensure non-hash for the same path 127 | var link = el.getAttribute('href'); 128 | if (!hashbang && el.pathname === location.pathname && (el.hash || '#' === link)) return; 129 | 130 | // Check for unexpected protocols in the href, e.g. (mailto: or skype:) 131 | if (link && /^[a-z]+:/.test(link) && /^https?/.test(link)) return; 132 | 133 | // check target 134 | if (el.target) return; 135 | 136 | // x-origin 137 | if (!sameOrigin(el.href)) return; 138 | 139 | // rebuild path 140 | var path = el.pathname + el.search + (el.hash || ''); 141 | 142 | // strip leading "/[drive letter]:" on NW.js on Windows 143 | if (typeof process !== 'undefined' && path.match(/^\/[a-zA-Z]:\//)) { 144 | path = path.replace(/^\/[a-zA-Z]:\//, '/'); 145 | } 146 | 147 | var route = match(path); 148 | if(!route) return; 149 | 150 | e.preventDefault(); 151 | 152 | callback(route); 153 | } 154 | 155 | }; 156 | 157 | export default { 158 | makeRouterDriver 159 | }; -------------------------------------------------------------------------------- /dist/driver.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _toArray = function (arr) { return Array.isArray(arr) ? arr : Array.from(arr); }; 4 | 5 | var Router5 = require("router5").Router5; 6 | var Rx = require("rx"); 7 | 8 | // The set of valid sink functions includes synchronous state-affecting router functions that do not require a callback 9 | // and which do not have a significant return value other than the router object itself. 10 | var validSinkFuncs = ["add", "addNode", "canActivate", "deregisterComponent", "navigate", "registerComponent", "setOption", "start", "stop"]; 11 | 12 | function validateAndRemapSinkArgument(arg) { 13 | if (!arg || !arg.length) { 14 | return null; 15 | }; 16 | if (typeof arg === "string") { 17 | arg = [arg]; 18 | } else if (!(arg instanceof Array)) { 19 | throw new Error("A Router5 sink argument should be a string or an array of arguments, starting with a function name"); 20 | } 21 | if (validSinkFuncs.indexOf(arg[0]) === -1) { 22 | throw new Error("\"" + arg[0] + "\" is not the name of a valid sink function call for the Router5 driver"); 23 | } 24 | if (typeof arg[arg.length - 1] === "function") { 25 | throw new Error("Router5 invocations specifying callbacks should be made using the source (responses) object"); 26 | } 27 | return arg; 28 | } 29 | 30 | function createStateChange$(router, fname, args) { 31 | return Rx.Observable.create(function (observer) { 32 | try { 33 | router[fname].apply(router, args.concat(function (toState, fromState) { 34 | observer.onNext({ toState: toState, fromState: fromState }); 35 | })); 36 | } catch (e) { 37 | observer.onError(e); 38 | } 39 | }); 40 | } 41 | 42 | function createDone$(router, fname, args) { 43 | return Rx.Observable.create(function (observer) { 44 | try { 45 | router[fname].apply(router, args.concat(function () { 46 | observer.onNext(true); 47 | observer.onCompleted(); 48 | })); 49 | } catch (e) { 50 | observer.onError(e); 51 | } 52 | }); 53 | } 54 | 55 | function makeRouterDriver(routes, options) { 56 | var router = new Router5(routes, options); 57 | 58 | if (!options || !options.disableClickHandler) { 59 | var clickEventName = typeof document !== "undefined" && document.ontouchstart ? "touchstart" : "click"; 60 | var clickHandler = makeOnClick(options.base, options.useHash, function (path) { 61 | return router.matchPath(path); 62 | }, function (_ref) { 63 | var name = _ref.name; 64 | var params = _ref.params; 65 | return router.navigate(name, params); 66 | }); 67 | document.addEventListener(clickEventName, clickHandler, false); 68 | } 69 | 70 | // The request stream allows certain synchronous [compatible] methods to be called in the form ['funcName', ...args]. 71 | return function (request$) { 72 | request$.map(validateAndRemapSinkArgument).subscribe(function (_ref) { 73 | var _ref2 = _toArray(_ref); 74 | 75 | var fname = _ref2[0]; 76 | 77 | var args = _ref2.slice(1); 78 | 79 | router[fname].apply(router, args); 80 | }, function (err) { 81 | return console.error(err); 82 | }); 83 | 84 | return { 85 | start: function () { 86 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 87 | args[_key] = arguments[_key]; 88 | } 89 | 90 | return createDone$(router, "start", args); 91 | }, 92 | addListener: function () { 93 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 94 | args[_key] = arguments[_key]; 95 | } 96 | 97 | return createStateChange$(router, "addListener", args); 98 | }, 99 | addNodeListener: function () { 100 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 101 | args[_key] = arguments[_key]; 102 | } 103 | 104 | return createStateChange$(router, "addNodeListener", args); 105 | }, 106 | addRouteListener: function () { 107 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 108 | args[_key] = arguments[_key]; 109 | } 110 | 111 | return createStateChange$(router, "addRouteListener", args); 112 | }, 113 | areStatesDescendants: function () { 114 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 115 | args[_key] = arguments[_key]; 116 | } 117 | 118 | return router.areStatesDescendants.apply(router, args); 119 | }, 120 | navigate: function () { 121 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 122 | args[_key] = arguments[_key]; 123 | } 124 | 125 | return createDone$(router, "navigate", args); 126 | }, 127 | matchPath: function () { 128 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 129 | args[_key] = arguments[_key]; 130 | } 131 | 132 | return router.matchPath.apply(router, args); 133 | }, 134 | buildUrl: function () { 135 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 136 | args[_key] = arguments[_key]; 137 | } 138 | 139 | return router.buildUrl.apply(router, args); 140 | }, 141 | buildPath: function () { 142 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 143 | args[_key] = arguments[_key]; 144 | } 145 | 146 | return router.buildPath.apply(router, args); 147 | } }; 148 | }; 149 | } 150 | 151 | // The following is adapted from VisionMedia's page.js router 152 | // https://github.com/visionmedia/page.js/blob/master/index.js 153 | 154 | var makeOnClick = function makeOnClick(base, hashbang, match, callback) { 155 | /** 156 | * Event button. 157 | */ 158 | function which(e) { 159 | e = e || window.event; 160 | return null === e.which ? e.button : e.which; 161 | } 162 | 163 | /** 164 | * Check if `href` is the same origin. 165 | */ 166 | function sameOrigin(href) { 167 | var origin = location.protocol + "//" + location.hostname; 168 | if (location.port) origin += ":" + location.port; 169 | return href && 0 === href.indexOf(origin); 170 | } 171 | 172 | return function onclick(e) { 173 | 174 | if (1 !== which(e)) { 175 | return; 176 | }if (e.metaKey || e.ctrlKey || e.shiftKey) { 177 | return; 178 | }if (e.defaultPrevented) { 179 | return; 180 | } // ensure link 181 | var el = e.target; 182 | while (el && "A" !== el.nodeName) el = el.parentNode; 183 | if (!el || "A" !== el.nodeName) { 184 | return; 185 | } // Ignore if tag has 186 | // 1. "download" attribute 187 | // 2. rel="external" attribute 188 | if (el.hasAttribute("download") || el.getAttribute("rel") === "external") { 189 | return; 190 | } // ensure non-hash for the same path 191 | var link = el.getAttribute("href"); 192 | if (!hashbang && el.pathname === location.pathname && (el.hash || "#" === link)) { 193 | return; 194 | } // Check for unexpected protocols in the href, e.g. (mailto: or skype:) 195 | if (link && /^[a-z]+:/.test(link) && /^https?/.test(link)) { 196 | return; 197 | } // check target 198 | if (el.target) { 199 | return; 200 | } // x-origin 201 | if (!sameOrigin(el.href)) { 202 | return; 203 | } // rebuild path 204 | var path = el.pathname + el.search + (el.hash || ""); 205 | 206 | // strip leading "/[drive letter]:" on NW.js on Windows 207 | if (typeof process !== "undefined" && path.match(/^\/[a-zA-Z]:\//)) { 208 | path = path.replace(/^\/[a-zA-Z]:\//, "/"); 209 | } 210 | 211 | var route = match(path); 212 | if (!route) { 213 | return; 214 | }e.preventDefault(); 215 | 216 | callback(route); 217 | }; 218 | }; 219 | 220 | module.exports = { 221 | makeRouterDriver: makeRouterDriver 222 | }; 223 | --------------------------------------------------------------------------------