├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bower.json ├── build ├── CHANGELOG.md ├── README.md ├── index.js ├── lib │ ├── dash.js │ ├── dsl.js │ ├── events.js │ ├── invariant.js │ ├── links.js │ ├── locations │ │ ├── browser.js │ │ └── memory.js │ ├── logger.js │ ├── path.js │ ├── qs.js │ ├── router.js │ └── transition.js ├── package.json └── standalone.js ├── docs ├── api.md └── intro.md ├── examples ├── README.md ├── cherry-pick │ ├── .gitignore │ ├── README.md │ ├── app │ │ ├── app.js │ │ ├── loader.js │ │ ├── screens │ │ │ └── application │ │ │ │ ├── application.css │ │ │ │ ├── base.css │ │ │ │ ├── cherry.png │ │ │ │ ├── index.js │ │ │ │ └── screens │ │ │ │ ├── index │ │ │ │ ├── index.css │ │ │ │ └── index.js │ │ │ │ └── repo │ │ │ │ ├── index.js │ │ │ │ ├── repo.css │ │ │ │ └── screens │ │ │ │ ├── code │ │ │ │ ├── code.css │ │ │ │ └── index.js │ │ │ │ └── commits │ │ │ │ ├── commits.css │ │ │ │ └── index.js │ │ └── shared │ │ │ ├── github.js │ │ │ └── react-route.js │ ├── index.html │ ├── index.js │ ├── package.json │ └── webpack.config.js ├── hello-world-jquery │ ├── README.md │ ├── index.html │ ├── index.js │ ├── package.json │ ├── style.css │ └── webpack.config.js ├── hello-world-react │ ├── README.md │ ├── app │ │ ├── components.js │ │ └── index.js │ ├── index.html │ ├── index.js │ ├── package.json │ ├── style.css │ └── webpack.config.js ├── server-side-react │ ├── README.md │ ├── app │ │ ├── render.js │ │ ├── routes.js │ │ └── server.js │ ├── assets │ │ └── style.css │ ├── index.js │ └── package.json └── vanilla-blog │ ├── README.md │ ├── client │ ├── app.js │ ├── handler.js │ ├── screens │ │ └── app │ │ │ ├── app.js │ │ │ ├── index.js │ │ │ ├── screens │ │ │ ├── about │ │ │ │ ├── about.js │ │ │ │ ├── index.js │ │ │ │ └── templates │ │ │ │ │ └── about.html │ │ │ ├── faq │ │ │ │ ├── faq.js │ │ │ │ ├── index.js │ │ │ │ └── templates │ │ │ │ │ └── faq.html │ │ │ ├── home │ │ │ │ ├── home.js │ │ │ │ ├── index.js │ │ │ │ └── templates │ │ │ │ │ └── home.html │ │ │ └── posts │ │ │ │ ├── index.js │ │ │ │ ├── posts.js │ │ │ │ ├── screens │ │ │ │ ├── index │ │ │ │ │ └── index.js │ │ │ │ ├── search │ │ │ │ │ ├── index.js │ │ │ │ │ └── search.js │ │ │ │ └── show │ │ │ │ │ ├── index.js │ │ │ │ │ ├── show.js │ │ │ │ │ └── templates │ │ │ │ │ └── show.html │ │ │ │ └── templates │ │ │ │ └── posts.html │ │ │ └── templates │ │ │ └── app.html │ ├── shared │ │ └── base_handler.js │ └── styles │ │ └── app.css │ ├── index.html │ ├── index.js │ ├── package.json │ └── webpack.config.js ├── index.js ├── karma.conf-ci.js ├── karma.conf.js ├── lib ├── dash.js ├── dsl.js ├── events.js ├── invariant.js ├── links.js ├── locations │ ├── browser.js │ └── memory.js ├── logger.js ├── path.js ├── qs.js ├── router.js └── transition.js ├── logo.png ├── logo.pxm ├── package.json ├── tasks └── build.sh ├── tests ├── functional │ ├── pushStateTest.js │ ├── routerTest.js │ └── testApp.js ├── index.js ├── lib │ └── fakeHistory.js └── unit │ ├── dashTest.js │ ├── linksTest.js │ ├── pathTest.js │ └── routerTest.js ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | sauce.json 4 | .sublimelinterrc 5 | examples/*/dist 6 | coverage -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "6" 5 | before_install: 6 | - "export DISPLAY=:99.0" 7 | - "sh -e /etc/init.d/xvfb start" 8 | script: "npm test" 9 | env: 10 | global: 11 | - SAUCE_USERNAME=cherrytree 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v2.4.1 2 | 3 | * Fix a broken release, for some reason the `npm run release` failed to package correctly 4 | 5 | ### v2.4.0 6 | 7 | * Make it possible to `transitionTo('anAbstractRoute')` and `generate('anAbstractRoute')` in cases where the abstract route has a corresponding index route. This can be more intuitive in some cases. 8 | 9 | ### v2.3.2 10 | 11 | * URL encode slashes in route params 12 | 13 | ### v2.3.1 14 | 15 | * Don't intercept clicks on `mailto:` links 16 | 17 | ### v2.2.1 18 | 19 | * Fix: stop using Array.prototype.find which is not available in older browsers 20 | 21 | ### v2.2.0 22 | 23 | * Add router.isActive method for testing if a given route is currently active. See [docs](docs/api.md#routerisactivename-params-query) 24 | 25 | ### v2.1.0 26 | 27 | * Parse query params when transitioning even when no route matches 28 | 29 | ### v2.0.0 30 | 31 | Nothing changed from v2.0.0-rc4. 32 | 33 | ### v2.0.0-rc4 34 | 35 | * BrowserLocation and HistoryLocation can now be accessed at cherrytree.BrowserLocation and cherrytree.MemoryLocation again. This is to make it easier to use those modules for UMD users (#116). 36 | 37 | ### v2.0.0-rc3 38 | 39 | Breaking changes: 40 | 41 | * `HistoryLocation` has been renamed to `BrowserLocation`. Location in cherrytree is the place that stores the current location of the app. Location is updated with the new path when cherytree transitions. Location also triggers updates when someone changes the location externally (e.g. by navigating with back/forward buttons or updating the URL). `BrowserLocation` is a more apt name since this location implementation represents browser's location bar and is configurable to use pushState or hashchange. This way, the other location that ships with cherrytree, `MemoryLocation`- also makes more sense, in this case we're saying the URL is simply stored in this in memory object and not really connected to the browser (which is what makes it useful on the server, for example). 42 | 43 | ### v2.0.0-rc2 44 | 45 | * Fix: query params were stringified incorrectly when more than 2 params and when some of params were undefined. `router.generate('/a/b/c', {}, { id: 'def', foo: 'bar', baz: undefined })` results in `/a/b/c?id=def&foo=bar` now as in the older versions of cherrytree. 46 | 47 | ### v2.0.0-rc1 48 | 49 | Breaking changes: 50 | 51 | * Every route is now routable. Previously it was only possible to generate links and transition to leaf routes. This simplifies the typical usage of the router and opens up new use cases as well. For example, if you want to redirect from '/' to '/some/:id', it's now easier to implement this kind of redirect behaviour without needing to create many reduntant '.index' routes. 52 | * The special `.index` treatment has been removed. Previously, if the route name ended with `.index`, the path was automatically set to ''. Now, such path will default to 'index' as with all other routes. Set `path: ''` on your index routes when upgrading. 53 | * An exception is now thrown when multiple routes have the same URL pattern. 54 | * Given all the above changes - a new route option `abstract: true` was introduced for making non leaf routes non routable. This also solves the problem where using `path: ''` would result in multiple routes with the same path. 55 | * The `paramNames` array (e.g. ['id', 'filter']) was replaced with `params` object (e.g. {id: 1, filter: 'foo'}) in the route descriptor on the transition object. 56 | * The `ancestors` attribute was removed from the route descriptor. 57 | * Switching between using `history` and `memory` locations has been simplified. Previously, you'd need to pass `new MemoryLocation(path)` when calling `listen`. Now, specify the location to use with `location: 'memory'` when creating the router and pass the path when calling `listen`. 58 | * The `qs` module was removed from dependencies and was replaced with a tiny, simple query string parser. This can be sufficient for a lot of applications and saves a couple of kilobytes. If you want to use `qs` or any other query parsing module, pass it as `qs: require('qs')` option to the router. 59 | * params, query and route array are now immutable between transitions, i.e. modifying those directly on the transition only affects that transition 60 | * Drop out-of-the-box support for ES3 environments (IE8). To use Cherrytree in older environments - es5 polyfills for native `map`, `reduce` and `forEach` need to be used now. 61 | * An undocumented, noop function `reset` was removed from the router. 62 | 63 | New features: 64 | 65 | * Support for custom [click intercept handlers](docs/api.md#intercepting-links) 66 | 67 | Under the hood improvements: 68 | 69 | * Update all dependencies to the latest versions 70 | * Tests are being run in more browsers now 71 | * Replaced `co` with `babel-creed-async` in tests 72 | * Removed the dependency on `lodash` 73 | 74 | Documentation: 75 | 76 | * Moved docs back to a separate [`docs/api.md`](docs/api.md) file 77 | * Documented [router.matchers](docs/api.md#routermatchers) 78 | * Documented [404 handling](docs/api.md#handling-404) 79 | 80 | ### v2.0.0-alpha.12 81 | 82 | * BYOP - Cherrytree now requires a global Promise implementation to be available or a Promise constructor passed in as an option 83 | 84 | ### v2.0.0-alpha.11 85 | 86 | * Add `transition.redirectTo` so that middleware could initiate redirects without having the router 87 | 88 | ### v2.0.0-alpha.10 89 | 90 | * Log errors by default (i.e. options.logError: true by default) 91 | 92 | ### v2.0.0-alpha.9 93 | 94 | * Fix router.destroy() - DOM click events for link interception are now cleaned up when router.destroy() is called 95 | * Add server side support 96 | * events.js now exports an {} object on the server instead of crashing due to missing `window` 97 | * MemoryLocation correctly handles option flags and can be instantiated with a starting `path` 98 | * Add a [server-side-react example](../examples/server-side-react) 99 | * When transition is rejected with a `TransitionRedirected` error - the `err.nextPath` is now available) 100 | 101 | ### v2.0.0-alpha.8 102 | 103 | * Fix dependencies - lodash was declared as a devDependency 104 | 105 | ### v2.0.0-alpha.7 106 | 107 | * Fix the URL generation when `pushState: true` and root !== '/' 108 | 109 | ### v2.0.0-alpha.1 110 | 111 | A brand new and improved cherrytree! 112 | 113 | ### v0.x.x 114 | 115 | See https://github.com/QubitProducts/cherrytree/tree/677f2c915780d712968023b8d24306ff787a426d 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | Cherrytree is a flexible hierarchical router that translates every URL change into a transition descriptor object and calls your middleware functions that put the application into a desired state. 4 | 5 | 6 | ## Installation 7 | 8 | The size excluding all deps is ~4.83kB gzipped and the standalone build with all deps is ~7.24kB gzipped. 9 | 10 | $ npm install --save cherrytree 11 | 12 | In a CJS environment 13 | 14 | require('cherrytree') 15 | 16 | In an AMD environment, require the standalone UMD build - this version has all of the dependencies bundled 17 | 18 | require('cherrytree/standalone') 19 | 20 | 21 | ## Docs 22 | 23 | * [Intro Guide](docs/intro.md) 24 | * [API Docs](docs/api.md) 25 | * [Changelog](CHANGELOG.md) 26 | 27 | 28 | ## Demo 29 | 30 | See it in action in [this demo](http://kidkarolis.github.io/cherrytree-redux-react-example). 31 | 32 | 33 | ## Plugins 34 | 35 | To use `cherrytree` with React, check out [`cherrytree-for-react`](https://github.com/KidkArolis/cherrytree-for-react). 36 | 37 | 38 | ## Usage 39 | 40 | ```js 41 | var cherrytree = require('cherrytree') 42 | 43 | // create the router 44 | var router = cherrytree() 45 | var handlers = require('./handlers') 46 | 47 | // provide your route map 48 | router.map(function (route) { 49 | route('application', {path: '/', abstract: true}, function () { 50 | route('feed', {path: ''}) 51 | route('messages') 52 | route('status', {path: ':user/status/:id'}) 53 | route('profile', {path: ':user'}, function () { 54 | route('profile.lists') 55 | route('profile.edit') 56 | }) 57 | }) 58 | }) 59 | 60 | router.use(function render (transition) { 61 | transition.routes.forEach(function (route, i) { 62 | route.view = handlers[route.name]({ 63 | params: transition.params, 64 | query: transition.query 65 | }) 66 | var parent = transition.routes[i-1] 67 | var containerEl = parent ? parent.view.el.querySelector('.outlet') : document.body 68 | containerEl.appendChild(view.render().el) 69 | }) 70 | }) 71 | 72 | router.use(function errorHandler (transition) { 73 | transition.catch(function (err) { 74 | if (err.type !== 'TransitionCancelled' && err.type !== 'TransitionRedirected') { 75 | console.error(err.stack) 76 | } 77 | }) 78 | }) 79 | 80 | // start listening to URL changes 81 | router.listen() 82 | ``` 83 | 84 | 85 | ## Examples 86 | 87 | You can clone this repo if you want to run the `examples` locally: 88 | 89 | * [hello-world-react](examples/hello-world-react) - best for first introduction 90 | * [hello-world-jquery](examples/hello-world-jquery) - a single file example 91 | * [cherry-pick](examples/cherry-pick) - a mini GitHub clone written in React.js 92 | * [vanilla-blog](examples/vanilla-blog) - a small static demo of blog like app that uses no framework 93 | * [server-side-react](examples/server-side-react) - a server side express app using cherrytree for routing and react for rendering 94 | 95 | A more complex example in it's own repo: 96 | 97 | * [cherrytree-redux-react-example](https://github.com/KidkArolis/cherrytree-redux-react-example) - a more modern stack - redux + react + react-hot-loader + cherrytree-for-react 98 | 99 | 100 | ## Features 101 | 102 | * can be used with any view and data framework 103 | * nested routes are great for nested UIs 104 | * generate links in a systematic way, e.g. `router.generate('commit', {sha: '1e2760'})` 105 | * use pushState with automatic hashchange fallback 106 | * all urls are generated with or without `#` as appropriate 107 | * link clicks on the page are intercepted automatically when using pushState 108 | * dynamically load parts of your app during transitions 109 | * dynamic segments, optional params and query params 110 | * support for custom query string parser 111 | * transition is a first class citizen - abort, pause, resume, retry. E.g. pause the transition to display "There are unsaved changes" message if the user clicked some link on the page or used browser's back/forward buttons 112 | * navigate around the app programatically, e.g. `router.transitionTo('commits')` 113 | * easily rename URL segments in a single place (e.g. /account -> /profile) 114 | 115 | 116 | ## How does it compare to other routers? 117 | 118 | * **Backbone router** is nice and simple and can often be enough. In fact cherrytree uses some bits from Backbone router under the hood. Cherrytree adds nested routing, support for asynchronous transitions, more flexible dynamic params, url generation, automatic click handling for pushState. 119 | * **Ember router / router.js** is the inspiration for cherrytree. It's where cherrytree inherits the idea of declaring hierarchical nested route maps. The scope of cherrytree is slightly different than that of router.js, for example cherrytree doesn't have the concept of handler objects or model hooks. On the other hand, unlike router.js - cherrytree handles browser url changes and intercepts link clicks with pushState out of the box. The handler concept and model hooks can be implemented based on the specific application needs using the middleware mechanism. Overall, cherrytree is less prescriptive, more flexible and easier to use out of the box. 120 | * **react-router** is also inspired by router.js. React-router is trying to solve a lot of routing related aspects out of the box in the most React idiomatic way whereas with `cherrytree` you'll have to write the glue code for integrating into React yourself (see [`cherrytree-for-react` plugin](https://github.com/KidkArolis/cherrytree-for-react)). However, what you get instead is a smaller, simpler and hopefully more flexible library which should be more adaptable to your specific needs. This also means that you can use a `react-router` like approach with other `React` inspired libraries such as `mercury`, `riot`, `om`, `cycle`, `deku` and so on. 121 | 122 | 123 | ## CI 124 | 125 | [![Build Status](https://travis-ci.org/QubitProducts/cherrytree.svg?branch=master)](https://travis-ci.org/QubitProducts/cherrytree) 126 | [![build status](https://codeship.com/projects/aa5e37b0-aeb1-0131-dd5f-06fd12e6a611/status?branch=master)](https://codeship.com/projects/19734) 127 | 128 | 129 | ## Browser Support 130 | 131 | [![Sauce Test Status](https://saucelabs.com/browser-matrix/cherrytree.svg)](https://saucelabs.com/u/cherrytree) 132 | 133 | Cherrytree works in all modern browsers. It requires es5 environment and es6 promises. Use polyfills for those if you have to support older browsers, e.g.: 134 | 135 | * https://github.com/es-shims/es5-shim 136 | * https://github.com/jakearchibald/es6-promise 137 | 138 | ## Acknowledgement 139 | 140 | Thanks to Marko Stupić for giving Cherrytree a logo from his http://icon-a-day.com/ project! 141 | 142 | ## FAQ 143 | 144 | * Why is `cherrytree` written as one word? You got me, I'd say that represents the [wabisabi](https://en.wikipedia.org/wiki/Wabi-sabi) nature of the library. 145 | 146 | ## Want to work on this for your day job? 147 | 148 | This project was created by the Engineering team at [Qubit](http://www.qubit.com). As we use open source libraries, we make our projects public where possible. 149 | 150 | We’re currently looking to grow our team, so if you’re a JavaScript engineer and keen on ES2016 React+Redux applications and Node micro services, why not get in touch? Work with like minded engineers in an environment that has fantastic perks, including an annual ski trip, yoga, a competitive foosball league, and copious amounts of yogurt. 151 | 152 | Find more details on our [Engineering site](https://eng.qubit.com). Don’t have an up to date CV? Just link us your Github profile! Better yet, send us a pull request that improves this project. 153 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cherrytree", 3 | "description": "Cherrytree - a flexible hierarchical client side router", 4 | "main": "build/standalone.js", 5 | "authors": [ 6 | "Karolis Narkevicius " 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "router", 11 | "history", 12 | "browser", 13 | "pushState", 14 | "hierarchical", 15 | "nested" 16 | ], 17 | "homepage": "https://github.com/QubitProducts/cherrytree", 18 | "moduleType": [ 19 | "amd", 20 | "es6", 21 | "globals" 22 | ], 23 | "ignore": [ 24 | "**/.*", 25 | "node_modules", 26 | "bower_components", 27 | "coverage", 28 | "tasks", 29 | "tests", 30 | "karma.*.js", 31 | "webpack.config.js" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /build/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v2.4.0 2 | 3 | * Make it possible to `transitionTo('anAbstractRoute')` and `generate('anAbstractRoute')` in cases where the abstract route has a corresponding index route. This can be more intuitive in some cases. 4 | 5 | ### v2.3.2 6 | 7 | * URL encode slashes in route params 8 | 9 | ### v2.3.1 10 | 11 | * Don't intercept clicks on `mailto:` links 12 | 13 | ### v2.2.1 14 | 15 | * Fix: stop using Array.prototype.find which is not available in older browsers 16 | 17 | ### v2.2.0 18 | 19 | * Add router.isActive method for testing if a given route is currently active. See [docs](docs/api.md#routerisactivename-params-query) 20 | 21 | ### v2.1.0 22 | 23 | * Parse query params when transitioning even when no route matches 24 | 25 | ### v2.0.0 26 | 27 | Nothing changed from v2.0.0-rc4. 28 | 29 | ### v2.0.0-rc4 30 | 31 | * BrowserLocation and HistoryLocation can now be accessed at cherrytree.BrowserLocation and cherrytree.MemoryLocation again. This is to make it easier to use those modules for UMD users (#116). 32 | 33 | ### v2.0.0-rc3 34 | 35 | Breaking changes: 36 | 37 | * `HistoryLocation` has been renamed to `BrowserLocation`. Location in cherrytree is the place that stores the current location of the app. Location is updated with the new path when cherytree transitions. Location also triggers updates when someone changes the location externally (e.g. by navigating with back/forward buttons or updating the URL). `BrowserLocation` is a more apt name since this location implementation represents browser's location bar and is configurable to use pushState or hashchange. This way, the other location that ships with cherrytree, `MemoryLocation`- also makes more sense, in this case we're saying the URL is simply stored in this in memory object and not really connected to the browser (which is what makes it useful on the server, for example). 38 | 39 | ### v2.0.0-rc2 40 | 41 | * Fix: query params were stringified incorrectly when more than 2 params and when some of params were undefined. `router.generate('/a/b/c', {}, { id: 'def', foo: 'bar', baz: undefined })` results in `/a/b/c?id=def&foo=bar` now as in the older versions of cherrytree. 42 | 43 | ### v2.0.0-rc1 44 | 45 | Breaking changes: 46 | 47 | * Every route is now routable. Previously it was only possible to generate links and transition to leaf routes. This simplifies the typical usage of the router and opens up new use cases as well. For example, if you want to redirect from '/' to '/some/:id', it's now easier to implement this kind of redirect behaviour without needing to create many reduntant '.index' routes. 48 | * The special `.index` treatment has been removed. Previously, if the route name ended with `.index`, the path was automatically set to ''. Now, such path will default to 'index' as with all other routes. Set `path: ''` on your index routes when upgrading. 49 | * An exception is now thrown when multiple routes have the same URL pattern. 50 | * Given all the above changes - a new route option `abstract: true` was introduced for making non leaf routes non routable. This also solves the problem where using `path: ''` would result in multiple routes with the same path. 51 | * The `paramNames` array (e.g. ['id', 'filter']) was replaced with `params` object (e.g. {id: 1, filter: 'foo'}) in the route descriptor on the transition object. 52 | * The `ancestors` attribute was removed from the route descriptor. 53 | * Switching between using `history` and `memory` locations has been simplified. Previously, you'd need to pass `new MemoryLocation(path)` when calling `listen`. Now, specify the location to use with `location: 'memory'` when creating the router and pass the path when calling `listen`. 54 | * The `qs` module was removed from dependencies and was replaced with a tiny, simple query string parser. This can be sufficient for a lot of applications and saves a couple of kilobytes. If you want to use `qs` or any other query parsing module, pass it as `qs: require('qs')` option to the router. 55 | * params, query and route array are now immutable between transitions, i.e. modifying those directly on the transition only affects that transition 56 | * Drop out-of-the-box support for ES3 environments (IE8). To use Cherrytree in older environments - es5 polyfills for native `map`, `reduce` and `forEach` need to be used now. 57 | * An undocumented, noop function `reset` was removed from the router. 58 | 59 | New features: 60 | 61 | * Support for custom [click intercept handlers](docs/api.md#intercepting-links) 62 | 63 | Under the hood improvements: 64 | 65 | * Update all dependencies to the latest versions 66 | * Tests are being run in more browsers now 67 | * Replaced `co` with `babel-creed-async` in tests 68 | * Removed the dependency on `lodash` 69 | 70 | Documentation: 71 | 72 | * Moved docs back to a separate [`docs/api.md`](docs/api.md) file 73 | * Documented [router.matchers](docs/api.md#routermatchers) 74 | * Documented [404 handling](docs/api.md#handling-404) 75 | 76 | ### v2.0.0-alpha.12 77 | 78 | * BYOP - Cherrytree now requires a global Promise implementation to be available or a Promise constructor passed in as an option 79 | 80 | ### v2.0.0-alpha.11 81 | 82 | * Add `transition.redirectTo` so that middleware could initiate redirects without having the router 83 | 84 | ### v2.0.0-alpha.10 85 | 86 | * Log errors by default (i.e. options.logError: true by default) 87 | 88 | ### v2.0.0-alpha.9 89 | 90 | * Fix router.destroy() - DOM click events for link interception are now cleaned up when router.destroy() is called 91 | * Add server side support 92 | * events.js now exports an {} object on the server instead of crashing due to missing `window` 93 | * MemoryLocation correctly handles option flags and can be instantiated with a starting `path` 94 | * Add a [server-side-react example](../examples/server-side-react) 95 | * When transition is rejected with a `TransitionRedirected` error - the `err.nextPath` is now available) 96 | 97 | ### v2.0.0-alpha.8 98 | 99 | * Fix dependencies - lodash was declared as a devDependency 100 | 101 | ### v2.0.0-alpha.7 102 | 103 | * Fix the URL generation when `pushState: true` and root !== '/' 104 | 105 | ### v2.0.0-alpha.1 106 | 107 | A brand new and improved cherrytree! 108 | 109 | ### v0.x.x 110 | 111 | See https://github.com/QubitProducts/cherrytree/tree/677f2c915780d712968023b8d24306ff787a426d 112 | -------------------------------------------------------------------------------- /build/README.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | Cherrytree is a flexible hierarchical router that translates every URL change into a transition descriptor object and calls your middleware functions that put the application into a desired state. 4 | 5 | 6 | ## Installation 7 | 8 | The size excluding all deps is ~4.83kB gzipped and the standalone build with all deps is ~7.24kB gzipped. 9 | 10 | $ npm install --save cherrytree 11 | 12 | In a CJS environment 13 | 14 | require('cherrytree') 15 | 16 | In an AMD environment, require the standalone UMD build - this version has all of the dependencies bundled 17 | 18 | require('cherrytree/standalone') 19 | 20 | 21 | ## Docs 22 | 23 | * [Intro Guide](docs/intro.md) 24 | * [API Docs](docs/api.md) 25 | * [Changelog](CHANGELOG.md) 26 | 27 | 28 | ## Demo 29 | 30 | See it in action in [this demo](http://kidkarolis.github.io/cherrytree-redux-react-example). 31 | 32 | 33 | ## Plugins 34 | 35 | To use `cherrytree` with React, check out [`cherrytree-for-react`](https://github.com/KidkArolis/cherrytree-for-react). 36 | 37 | 38 | ## Usage 39 | 40 | ```js 41 | var cherrytree = require('cherrytree') 42 | 43 | // create the router 44 | var router = cherrytree() 45 | var handlers = require('./handlers') 46 | 47 | // provide your route map 48 | router.map(function (route) { 49 | route('application', {path: '/', abstract: true}, function () { 50 | route('feed', {path: ''}) 51 | route('messages') 52 | route('status', {path: ':user/status/:id'}) 53 | route('profile', {path: ':user'}, function () { 54 | route('profile.lists') 55 | route('profile.edit') 56 | }) 57 | }) 58 | }) 59 | 60 | router.use(function render (transition) { 61 | transition.routes.forEach(function (route, i) { 62 | route.view = handlers[route.name]({ 63 | params: transition.params, 64 | query: transition.query 65 | }) 66 | var parent = transition.routes[i-1] 67 | var containerEl = parent ? parent.view.el.querySelector('.outlet') : document.body 68 | containerEl.appendChild(view.render().el) 69 | }) 70 | }) 71 | 72 | router.use(function errorHandler (transition) { 73 | transition.catch(function (err) { 74 | if (err.type !== 'TransitionCancelled' && err.type !== 'TransitionRedirected') { 75 | console.error(err.stack) 76 | } 77 | }) 78 | }) 79 | 80 | // start listening to URL changes 81 | router.listen() 82 | ``` 83 | 84 | 85 | ## Examples 86 | 87 | You can clone this repo if you want to run the `examples` locally: 88 | 89 | * [hello-world-react](examples/hello-world-react) - best for first introduction 90 | * [hello-world-jquery](examples/hello-world-jquery) - a single file example 91 | * [cherry-pick](examples/cherry-pick) - a mini GitHub clone written in React.js 92 | * [vanilla-blog](examples/vanilla-blog) - a small static demo of blog like app that uses no framework 93 | * [server-side-react](examples/server-side-react) - a server side express app using cherrytree for routing and react for rendering 94 | 95 | A more complex example in it's own repo: 96 | 97 | * [cherrytree-redux-react-example](https://github.com/KidkArolis/cherrytree-redux-react-example) - a more modern stack - redux + react + react-hot-loader + cherrytree-for-react 98 | 99 | 100 | ## Features 101 | 102 | * can be used with any view and data framework 103 | * nested routes are great for nested UIs 104 | * generate links in a systematic way, e.g. `router.generate('commit', {sha: '1e2760'})` 105 | * use pushState with automatic hashchange fallback 106 | * all urls are generated with or without `#` as appropriate 107 | * link clicks on the page are intercepted automatically when using pushState 108 | * dynamically load parts of your app during transitions 109 | * dynamic segments, optional params and query params 110 | * support for custom query string parser 111 | * transition is a first class citizen - abort, pause, resume, retry. E.g. pause the transition to display "There are unsaved changes" message if the user clicked some link on the page or used browser's back/forward buttons 112 | * navigate around the app programatically, e.g. `router.transitionTo('commits')` 113 | * easily rename URL segments in a single place (e.g. /account -> /profile) 114 | 115 | 116 | ## How does it compare to other routers? 117 | 118 | * **Backbone router** is nice and simple and can often be enough. In fact cherrytree uses some bits from Backbone router under the hood. Cherrytree adds nested routing, support for asynchronous transitions, more flexible dynamic params, url generation, automatic click handling for pushState. 119 | * **Ember router / router.js** is the inspiration for cherrytree. It's where cherrytree inherits the idea of declaring hierarchical nested route maps. The scope of cherrytree is slightly different than that of router.js, for example cherrytree doesn't have the concept of handler objects or model hooks. On the other hand, unlike router.js - cherrytree handles browser url changes and intercepts link clicks with pushState out of the box. The handler concept and model hooks can be implemented based on the specific application needs using the middleware mechanism. Overall, cherrytree is less prescriptive, more flexible and easier to use out of the box. 120 | * **react-router** is also inspired by router.js. React-router is trying to solve a lot of routing related aspects out of the box in the most React idiomatic way whereas with `cherrytree` you'll have to write the glue code for integrating into React yourself (see [`cherrytree-for-react` plugin](https://github.com/KidkArolis/cherrytree-for-react)). However, what you get instead is a smaller, simpler and hopefully more flexible library which should be more adaptable to your specific needs. This also means that you can use a `react-router` like approach with other `React` inspired libraries such as `mercury`, `riot`, `om`, `cycle`, `deku` and so on. 121 | 122 | 123 | ## CI 124 | 125 | [![Build Status](https://travis-ci.org/QubitProducts/cherrytree.svg?branch=master)](https://travis-ci.org/QubitProducts/cherrytree) 126 | [![build status](https://codeship.com/projects/aa5e37b0-aeb1-0131-dd5f-06fd12e6a611/status?branch=master)](https://codeship.com/projects/19734) 127 | 128 | 129 | ## Browser Support 130 | 131 | [![Sauce Test Status](https://saucelabs.com/browser-matrix/cherrytree.svg)](https://saucelabs.com/u/cherrytree) 132 | 133 | Cherrytree works in all modern browsers. It requires es5 environment and es6 promises. Use polyfills for those if you have to support older browsers, e.g.: 134 | 135 | * https://github.com/es-shims/es5-shim 136 | * https://github.com/jakearchibald/es6-promise 137 | 138 | ## Acknowledgement 139 | 140 | Thanks to Marko Stupić for giving Cherrytree a logo from his http://icon-a-day.com/ project! 141 | 142 | ## FAQ 143 | 144 | * Why is `cherrytree` written as one word? You got me, I'd say that represents the [wabisabi](https://en.wikipedia.org/wiki/Wabi-sabi) nature of the library. 145 | 146 | ## Want to work on this for your day job? 147 | 148 | This project was created by the Engineering team at [Qubit](http://www.qubit.com). As we use open source libraries, we make our projects public where possible. 149 | 150 | We’re currently looking to grow our team, so if you’re a JavaScript engineer and keen on ES2016 React+Redux applications and Node micro services, why not get in touch? Work with like minded engineers in an environment that has fantastic perks, including an annual ski trip, yoga, a competitive foosball league, and copious amounts of yogurt. 151 | 152 | Find more details on our [Engineering site](https://eng.qubit.com). Don’t have an up to date CV? Just link us your Github profile! Better yet, send us a pull request that improves this project. 153 | -------------------------------------------------------------------------------- /build/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/router') 2 | -------------------------------------------------------------------------------- /build/lib/dash.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | var toString = Object.prototype.toString; 7 | var keys = Object.keys; 8 | var assoc = function assoc(obj, attr, val) { 9 | obj[attr] = val;return obj; 10 | }; 11 | var isArray = function isArray(obj) { 12 | return toString.call(obj) === '[object Array]'; 13 | }; 14 | 15 | var clone = function clone(obj) { 16 | return obj ? isArray(obj) ? obj.slice(0) : extend({}, obj) : obj; 17 | }; 18 | 19 | exports.clone = clone; 20 | var pick = function pick(obj, attrs) { 21 | return attrs.reduce(function (acc, attr) { 22 | return obj[attr] === undefined ? acc : assoc(acc, attr, obj[attr]); 23 | }, {}); 24 | }; 25 | 26 | exports.pick = pick; 27 | var isEqual = function isEqual(obj1, obj2) { 28 | return keys(obj1).length === keys(obj2).length && keys(obj1).reduce(function (acc, key) { 29 | return acc && obj2[key] === obj1[key]; 30 | }, true); 31 | }; 32 | 33 | exports.isEqual = isEqual; 34 | var extend = function extend(obj) { 35 | for (var _len = arguments.length, rest = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { 36 | rest[_key - 1] = arguments[_key]; 37 | } 38 | 39 | rest.forEach(function (source) { 40 | if (source) { 41 | for (var prop in source) { 42 | obj[prop] = source[prop]; 43 | } 44 | } 45 | }); 46 | return obj; 47 | }; 48 | 49 | exports.extend = extend; 50 | var find = function find(list, pred) { 51 | var _iteratorNormalCompletion = true; 52 | var _didIteratorError = false; 53 | var _iteratorError = undefined; 54 | 55 | try { 56 | for (var _iterator = list[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { 57 | var x = _step.value; 58 | if (pred(x)) return x; 59 | } 60 | } catch (err) { 61 | _didIteratorError = true; 62 | _iteratorError = err; 63 | } finally { 64 | try { 65 | if (!_iteratorNormalCompletion && _iterator['return']) { 66 | _iterator['return'](); 67 | } 68 | } finally { 69 | if (_didIteratorError) { 70 | throw _iteratorError; 71 | } 72 | } 73 | } 74 | }; 75 | 76 | exports.find = find; 77 | var isString = function isString(obj) { 78 | return Object.prototype.toString.call(obj) === '[object String]'; 79 | }; 80 | exports.isString = isString; -------------------------------------------------------------------------------- /build/lib/dsl.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | exports['default'] = dsl; 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 9 | 10 | var _dash = require('./dash'); 11 | 12 | var _invariant = require('./invariant'); 13 | 14 | var _invariant2 = _interopRequireDefault(_invariant); 15 | 16 | function dsl(callback) { 17 | var ancestors = []; 18 | var matches = {}; 19 | var names = {}; 20 | 21 | callback(function route(name, options, callback) { 22 | var routes = undefined; 23 | 24 | (0, _invariant2['default'])(!names[name], 'Route names must be unique, but route "%s" is declared multiple times', name); 25 | 26 | names[name] = true; 27 | 28 | if (arguments.length === 1) { 29 | options = {}; 30 | } 31 | 32 | if (arguments.length === 2 && typeof options === 'function') { 33 | callback = options; 34 | options = {}; 35 | } 36 | 37 | if (typeof options.path !== 'string') { 38 | var parts = name.split('.'); 39 | options.path = parts[parts.length - 1]; 40 | } 41 | 42 | // go to the next level 43 | if (callback) { 44 | ancestors = ancestors.concat(name); 45 | callback(); 46 | routes = pop(); 47 | ancestors.splice(-1); 48 | } 49 | 50 | // add the node to the tree 51 | push({ 52 | name: name, 53 | path: options.path, 54 | routes: routes || [], 55 | options: options, 56 | ancestors: (0, _dash.clone)(ancestors) 57 | }); 58 | }); 59 | 60 | function pop() { 61 | return matches[currentLevel()] || []; 62 | } 63 | 64 | function push(route) { 65 | matches[currentLevel()] = matches[currentLevel()] || []; 66 | matches[currentLevel()].push(route); 67 | } 68 | 69 | function currentLevel() { 70 | return ancestors.join('.'); 71 | } 72 | 73 | return pop(); 74 | } 75 | 76 | module.exports = exports['default']; -------------------------------------------------------------------------------- /build/lib/events.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | var events = createEvents(); 7 | 8 | exports['default'] = events; 9 | 10 | function createEvents() { 11 | var exp = {}; 12 | 13 | if (typeof window === 'undefined') { 14 | return exp; 15 | } 16 | 17 | /** 18 | * DOM Event bind/unbind 19 | */ 20 | 21 | var bind = window.addEventListener ? 'addEventListener' : 'attachEvent'; 22 | var unbind = window.removeEventListener ? 'removeEventListener' : 'detachEvent'; 23 | var prefix = bind !== 'addEventListener' ? 'on' : ''; 24 | 25 | /** 26 | * Bind `el` event `type` to `fn`. 27 | * 28 | * @param {Element} el 29 | * @param {String} type 30 | * @param {Function} fn 31 | * @param {Boolean} capture 32 | * @return {Function} 33 | * @api public 34 | */ 35 | 36 | exp.bind = function (el, type, fn, capture) { 37 | el[bind](prefix + type, fn, capture || false); 38 | return fn; 39 | }; 40 | 41 | /** 42 | * Unbind `el` event `type`'s callback `fn`. 43 | * 44 | * @param {Element} el 45 | * @param {String} type 46 | * @param {Function} fn 47 | * @param {Boolean} capture 48 | * @return {Function} 49 | * @api public 50 | */ 51 | 52 | exp.unbind = function (el, type, fn, capture) { 53 | el[unbind](prefix + type, fn, capture || false); 54 | return fn; 55 | }; 56 | 57 | return exp; 58 | } 59 | module.exports = exports['default']; -------------------------------------------------------------------------------- /build/lib/invariant.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | exports['default'] = invariant; 7 | 8 | function invariant(condition, format, a, b, c, d, e, f) { 9 | if (!condition) { 10 | (function () { 11 | var args = [a, b, c, d, e, f]; 12 | var argIndex = 0; 13 | var error = new Error('Invariant Violation: ' + format.replace(/%s/g, function () { 14 | return args[argIndex++]; 15 | })); 16 | error.framesToPop = 1; // we don't care about invariant's own frame 17 | throw error; 18 | })(); 19 | } 20 | } 21 | 22 | module.exports = exports['default']; -------------------------------------------------------------------------------- /build/lib/links.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | exports.intercept = intercept; 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 9 | 10 | var _events = require('./events'); 11 | 12 | var _events2 = _interopRequireDefault(_events); 13 | 14 | /** 15 | * Handle link delegation on `el` or the document, 16 | * and invoke `fn(e)` when clickable. 17 | * 18 | * @param {Element|Function} el or fn 19 | * @param {Function} [fn] 20 | * @api public 21 | */ 22 | 23 | function intercept(el, fn) { 24 | // default to document 25 | if (typeof el === 'function') { 26 | fn = el; 27 | el = document; 28 | } 29 | 30 | var cb = delegate(el, 'click', function (e, el) { 31 | if (clickable(e, el)) fn(e, el); 32 | }); 33 | 34 | return function dispose() { 35 | undelegate(el, 'click', cb); 36 | }; 37 | } 38 | 39 | function link(element) { 40 | element = { parentNode: element }; 41 | 42 | var root = document; 43 | 44 | // Make sure `element !== document` and `element != null` 45 | // otherwise we get an illegal invocation 46 | while ((element = element.parentNode) && element !== document) { 47 | if (element.tagName.toLowerCase() === 'a') { 48 | return element; 49 | } 50 | // After `matches` on the edge case that 51 | // the selector matches the root 52 | // (when the root is not the document) 53 | if (element === root) { 54 | return; 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * Delegate event `type` to links 61 | * and invoke `fn(e)`. A callback function 62 | * is returned which may be passed to `.unbind()`. 63 | * 64 | * @param {Element} el 65 | * @param {String} selector 66 | * @param {String} type 67 | * @param {Function} fn 68 | * @param {Boolean} capture 69 | * @return {Function} 70 | * @api public 71 | */ 72 | 73 | function delegate(el, type, fn) { 74 | return _events2['default'].bind(el, type, function (e) { 75 | var target = e.target || e.srcElement; 76 | var el = link(target); 77 | if (el) { 78 | fn(e, el); 79 | } 80 | }); 81 | } 82 | 83 | /** 84 | * Unbind event `type`'s callback `fn`. 85 | * 86 | * @param {Element} el 87 | * @param {String} type 88 | * @param {Function} fn 89 | * @param {Boolean} capture 90 | * @api public 91 | */ 92 | 93 | function undelegate(el, type, fn) { 94 | _events2['default'].unbind(el, type, fn); 95 | } 96 | 97 | /** 98 | * Check if `e` is clickable. 99 | */ 100 | 101 | function clickable(e, el) { 102 | if (which(e) !== 1) return; 103 | if (e.metaKey || e.ctrlKey || e.shiftKey) return; 104 | if (e.defaultPrevented) return; 105 | 106 | // check target 107 | if (el.target) return; 108 | 109 | // check for data-bypass attribute 110 | if (el.getAttribute('data-bypass') !== null) return; 111 | 112 | // inspect the href 113 | var href = el.getAttribute('href'); 114 | if (!href || href.length === 0) return; 115 | // don't handle hash links 116 | if (href[0] === '#') return; 117 | // external/absolute links 118 | if (href.indexOf('http://') === 0 || href.indexOf('https://') === 0) return; 119 | // email links 120 | if (href.indexOf('mailto:') === 0) return; 121 | // don't intercept javascript links 122 | /* eslint-disable no-script-url */ 123 | if (href.indexOf('javascript:') === 0) return; 124 | /* eslint-enable no-script-url */ 125 | 126 | return true; 127 | } 128 | 129 | /** 130 | * Event button. 131 | */ 132 | 133 | function which(e) { 134 | e = e || window.event; 135 | return e.which === null ? e.button : e.which; 136 | } -------------------------------------------------------------------------------- /build/lib/locations/browser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | 7 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 8 | 9 | var _dash = require('../dash'); 10 | 11 | var _locationBar = require('location-bar'); 12 | 13 | var _locationBar2 = _interopRequireDefault(_locationBar); 14 | 15 | exports['default'] = BrowserLocation; 16 | 17 | function BrowserLocation(options) { 18 | this.path = options.path || ''; 19 | 20 | this.options = (0, _dash.extend)({ 21 | pushState: false, 22 | root: '/' 23 | }, options); 24 | 25 | // we're using the location-bar module for actual 26 | // URL management 27 | var self = this; 28 | this.locationBar = new _locationBar2['default'](); 29 | this.locationBar.onChange(function (path) { 30 | self.handleURL('/' + (path || '')); 31 | }); 32 | 33 | this.locationBar.start((0, _dash.extend)({}, options)); 34 | } 35 | 36 | /** 37 | * Check if we're actually using pushState. For browsers 38 | * that don't support it this would return false since 39 | * it would fallback to using hashState / polling 40 | * @return {Bool} 41 | */ 42 | 43 | BrowserLocation.prototype.usesPushState = function () { 44 | return this.options.pushState && this.locationBar.hasPushState(); 45 | }; 46 | 47 | /** 48 | * Get the current URL 49 | */ 50 | 51 | BrowserLocation.prototype.getURL = function () { 52 | return this.path; 53 | }; 54 | 55 | /** 56 | * Set the current URL without triggering any events 57 | * back to the router. Add a new entry in browser's history. 58 | */ 59 | 60 | BrowserLocation.prototype.setURL = function (path, options) { 61 | if (this.path !== path) { 62 | this.path = path; 63 | this.locationBar.update(path, (0, _dash.extend)({ trigger: true }, options)); 64 | } 65 | }; 66 | 67 | /** 68 | * Set the current URL without triggering any events 69 | * back to the router. Replace the latest entry in broser's history. 70 | */ 71 | 72 | BrowserLocation.prototype.replaceURL = function (path, options) { 73 | if (this.path !== path) { 74 | this.path = path; 75 | this.locationBar.update(path, (0, _dash.extend)({ trigger: true, replace: true }, options)); 76 | } 77 | }; 78 | 79 | /** 80 | * Setup a URL change handler 81 | * @param {Function} callback 82 | */ 83 | BrowserLocation.prototype.onChange = function (callback) { 84 | this.changeCallback = callback; 85 | }; 86 | 87 | /** 88 | * Given a path, generate a URL appending root 89 | * if pushState is used and # if hash state is used 90 | */ 91 | BrowserLocation.prototype.formatURL = function (path) { 92 | if (this.locationBar.hasPushState()) { 93 | var rootURL = this.options.root; 94 | if (path !== '') { 95 | rootURL = rootURL.replace(/\/$/, ''); 96 | } 97 | return rootURL + path; 98 | } else { 99 | if (path[0] === '/') { 100 | path = path.substr(1); 101 | } 102 | return '#' + path; 103 | } 104 | }; 105 | 106 | /** 107 | * When we use pushState with a custom root option, 108 | * we need to take care of removingRoot at certain points. 109 | * Specifically 110 | * - browserLocation.update() can be called with the full URL by router 111 | * - LocationBar expects all .update() calls to be called without root 112 | * - this method is public so that we could dispatch URLs without root in router 113 | */ 114 | BrowserLocation.prototype.removeRoot = function (url) { 115 | if (this.options.pushState && this.options.root && this.options.root !== '/') { 116 | return url.replace(this.options.root, ''); 117 | } else { 118 | return url; 119 | } 120 | }; 121 | 122 | /** 123 | * Stop listening to URL changes and link clicks 124 | */ 125 | BrowserLocation.prototype.destroy = function () { 126 | this.locationBar.stop(); 127 | }; 128 | 129 | /** 130 | initially, the changeCallback won't be defined yet, but that's good 131 | because we dont' want to kick off routing right away, the router 132 | does that later by manually calling this handleURL method with the 133 | url it reads of the location. But it's important this is called 134 | first by Backbone, because we wanna set a correct this.path value 135 | 136 | @private 137 | */ 138 | BrowserLocation.prototype.handleURL = function (url) { 139 | this.path = url; 140 | if (this.changeCallback) { 141 | this.changeCallback(url); 142 | } 143 | }; 144 | module.exports = exports['default']; -------------------------------------------------------------------------------- /build/lib/locations/memory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | 7 | var _dash = require('../dash'); 8 | 9 | exports['default'] = MemoryLocation; 10 | 11 | function MemoryLocation(options) { 12 | this.path = options.path || ''; 13 | } 14 | 15 | MemoryLocation.prototype.getURL = function () { 16 | return this.path; 17 | }; 18 | 19 | MemoryLocation.prototype.setURL = function (path, options) { 20 | if (this.path !== path) { 21 | this.path = path; 22 | this.handleURL(this.getURL(), options); 23 | } 24 | }; 25 | 26 | MemoryLocation.prototype.replaceURL = function (path, options) { 27 | if (this.path !== path) { 28 | this.setURL(path, options); 29 | } 30 | }; 31 | 32 | MemoryLocation.prototype.onChange = function (callback) { 33 | this.changeCallback = callback; 34 | }; 35 | 36 | MemoryLocation.prototype.handleURL = function (url, options) { 37 | this.path = url; 38 | options = (0, _dash.extend)({ trigger: true }, options); 39 | if (this.changeCallback && options.trigger) { 40 | this.changeCallback(url); 41 | } 42 | }; 43 | 44 | MemoryLocation.prototype.usesPushState = function () { 45 | return false; 46 | }; 47 | 48 | MemoryLocation.prototype.removeRoot = function (url) { 49 | return url; 50 | }; 51 | 52 | MemoryLocation.prototype.formatURL = function (url) { 53 | return url; 54 | }; 55 | module.exports = exports['default']; -------------------------------------------------------------------------------- /build/lib/logger.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports["default"] = createLogger; 7 | 8 | function createLogger(log, options) { 9 | options = options || {}; 10 | // falsy means no logging 11 | if (!log) return function () {}; 12 | // custom logging function 13 | if (log !== true) return log; 14 | // true means use the default logger - console 15 | var fn = options.error ? console.error : console.info; 16 | return function () { 17 | fn.apply(console, arguments); 18 | }; 19 | } 20 | 21 | module.exports = exports["default"]; -------------------------------------------------------------------------------- /build/lib/path.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | 7 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 8 | 9 | var _invariant = require('./invariant'); 10 | 11 | var _invariant2 = _interopRequireDefault(_invariant); 12 | 13 | var _pathToRegexp = require('path-to-regexp'); 14 | 15 | var _pathToRegexp2 = _interopRequireDefault(_pathToRegexp); 16 | 17 | var paramInjectMatcher = /:([a-zA-Z_$][a-zA-Z0-9_$?]*[?+*]?)/g; 18 | var specialParamChars = /[+*?]$/g; 19 | var queryMatcher = /\?(.+)/; 20 | 21 | var _compiledPatterns = {}; 22 | 23 | function compilePattern(pattern) { 24 | if (!(pattern in _compiledPatterns)) { 25 | var paramNames = []; 26 | var re = (0, _pathToRegexp2['default'])(pattern, paramNames); 27 | 28 | _compiledPatterns[pattern] = { 29 | matcher: re, 30 | paramNames: paramNames.map(function (p) { 31 | return p.name; 32 | }) 33 | }; 34 | } 35 | 36 | return _compiledPatterns[pattern]; 37 | } 38 | 39 | var Path = { 40 | /** 41 | * Returns true if the given path is absolute. 42 | */ 43 | isAbsolute: function isAbsolute(path) { 44 | return path.charAt(0) === '/'; 45 | }, 46 | 47 | /** 48 | * Joins two URL paths together. 49 | */ 50 | join: function join(a, b) { 51 | return a.replace(/\/*$/, '/') + b; 52 | }, 53 | 54 | /** 55 | * Returns an array of the names of all parameters in the given pattern. 56 | */ 57 | extractParamNames: function extractParamNames(pattern) { 58 | return compilePattern(pattern).paramNames; 59 | }, 60 | 61 | /** 62 | * Extracts the portions of the given URL path that match the given pattern 63 | * and returns an object of param name => value pairs. Returns null if the 64 | * pattern does not match the given path. 65 | */ 66 | extractParams: function extractParams(pattern, path) { 67 | var cp = compilePattern(pattern); 68 | var matcher = cp.matcher; 69 | var paramNames = cp.paramNames; 70 | var match = path.match(matcher); 71 | 72 | if (!match) { 73 | return null; 74 | } 75 | 76 | var params = {}; 77 | 78 | paramNames.forEach(function (paramName, index) { 79 | params[paramName] = match[index + 1] && decodeURIComponent(match[index + 1]); 80 | }); 81 | 82 | return params; 83 | }, 84 | 85 | /** 86 | * Returns a version of the given route path with params interpolated. Throws 87 | * if there is a dynamic segment of the route path for which there is no param. 88 | */ 89 | injectParams: function injectParams(pattern, params) { 90 | params = params || {}; 91 | 92 | return pattern.replace(paramInjectMatcher, function (match, param) { 93 | var paramName = param.replace(specialParamChars, ''); 94 | var lastChar = param.slice(-1); 95 | 96 | // If param is optional don't check for existence 97 | if (lastChar === '?' || lastChar === '*') { 98 | if (params[paramName] == null) { 99 | return ''; 100 | } 101 | } else { 102 | (0, _invariant2['default'])(params[paramName] != null, "Missing '%s' parameter for path '%s'", paramName, pattern); 103 | } 104 | 105 | var paramValue = encodeURIComponent(params[paramName]); 106 | if (lastChar === '*' || lastChar === '+') { 107 | // restore / for splats 108 | paramValue = paramValue.replace('%2F', '/'); 109 | } 110 | return paramValue; 111 | }); 112 | }, 113 | 114 | /** 115 | * Returns an object that is the result of parsing any query string contained 116 | * in the given path, null if the path contains no query string. 117 | */ 118 | extractQuery: function extractQuery(qs, path) { 119 | var match = path.match(queryMatcher); 120 | return match && qs.parse(match[1]); 121 | }, 122 | 123 | /** 124 | * Returns a version of the given path with the parameters in the given 125 | * query merged into the query string. 126 | */ 127 | withQuery: function withQuery(qs, path, query) { 128 | var queryString = qs.stringify(query, { indices: false }); 129 | 130 | if (queryString) { 131 | return Path.withoutQuery(path) + '?' + queryString; 132 | } 133 | 134 | return path; 135 | }, 136 | 137 | /** 138 | * Returns a version of the given path without the query string. 139 | */ 140 | withoutQuery: function withoutQuery(path) { 141 | return path.replace(queryMatcher, ''); 142 | } 143 | }; 144 | 145 | exports['default'] = Path; 146 | module.exports = exports['default']; -------------------------------------------------------------------------------- /build/lib/qs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | exports['default'] = { 7 | parse: function parse(querystring) { 8 | return querystring.split('&').reduce(function (acc, pair) { 9 | var parts = pair.split('='); 10 | acc[parts[0]] = decodeURIComponent(parts[1]); 11 | return acc; 12 | }, {}); 13 | }, 14 | 15 | stringify: function stringify(params) { 16 | return Object.keys(params).reduce(function (acc, key) { 17 | if (params[key] !== undefined) { 18 | acc.push(key + '=' + encodeURIComponent(params[key])); 19 | } 20 | return acc; 21 | }, []).join('&'); 22 | } 23 | }; 24 | module.exports = exports['default']; -------------------------------------------------------------------------------- /build/lib/transition.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | exports['default'] = transition; 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 9 | 10 | var _dash = require('./dash'); 11 | 12 | var _invariant = require('./invariant'); 13 | 14 | var _invariant2 = _interopRequireDefault(_invariant); 15 | 16 | function transition(options, Promise) { 17 | options = options || {}; 18 | 19 | var router = options.router; 20 | var log = router.log; 21 | var logError = router.logError; 22 | 23 | var path = options.path; 24 | var match = options.match; 25 | var routes = match.routes; 26 | var params = match.params; 27 | var pathname = match.pathname; 28 | var query = match.query; 29 | 30 | var id = options.id; 31 | var startTime = Date.now(); 32 | log('---'); 33 | log('Transition #' + id, 'to', path); 34 | log('Transition #' + id, 'routes:', routes.map(function (r) { 35 | return r.name; 36 | })); 37 | log('Transition #' + id, 'params:', params); 38 | log('Transition #' + id, 'query:', query); 39 | 40 | // create the transition promise 41 | var resolve = undefined, 42 | reject = undefined; 43 | var promise = new Promise(function (res, rej) { 44 | resolve = res; 45 | reject = rej; 46 | }); 47 | 48 | // 1. make transition errors loud 49 | // 2. by adding this handler we make sure 50 | // we don't trigger the default 'Potentially 51 | // unhandled rejection' for cancellations 52 | promise.then(function () { 53 | log('Transition #' + id, 'completed in', Date.now() - startTime + 'ms'); 54 | })['catch'](function (err) { 55 | if (err.type !== 'TransitionRedirected' && err.type !== 'TransitionCancelled') { 56 | log('Transition #' + id, 'FAILED'); 57 | logError(err.stack); 58 | } 59 | }); 60 | 61 | var cancelled = false; 62 | 63 | var transition = { 64 | id: id, 65 | prev: { 66 | routes: (0, _dash.clone)(router.state.routes) || [], 67 | path: router.state.path || '', 68 | pathname: router.state.pathname || '', 69 | params: (0, _dash.clone)(router.state.params) || {}, 70 | query: (0, _dash.clone)(router.state.query) || {} 71 | }, 72 | routes: (0, _dash.clone)(routes), 73 | path: path, 74 | pathname: pathname, 75 | params: (0, _dash.clone)(params), 76 | query: (0, _dash.clone)(query), 77 | redirectTo: function redirectTo() { 78 | return router.transitionTo.apply(router, arguments); 79 | }, 80 | retry: function retry() { 81 | return router.transitionTo(path); 82 | }, 83 | cancel: function cancel(err) { 84 | if (router.state.activeTransition !== transition) { 85 | return; 86 | } 87 | 88 | if (transition.isCancelled) { 89 | return; 90 | } 91 | 92 | router.state.activeTransition = null; 93 | transition.isCancelled = true; 94 | cancelled = true; 95 | 96 | if (!err) { 97 | err = new Error('TransitionCancelled'); 98 | err.type = 'TransitionCancelled'; 99 | } 100 | if (err.type === 'TransitionCancelled') { 101 | log('Transition #' + id, 'cancelled'); 102 | } 103 | if (err.type === 'TransitionRedirected') { 104 | log('Transition #' + id, 'redirected'); 105 | } 106 | 107 | reject(err); 108 | }, 109 | followRedirects: function followRedirects() { 110 | return promise['catch'](function (reason) { 111 | if (router.state.activeTransition) { 112 | return router.state.activeTransition.followRedirects(); 113 | } 114 | return Promise.reject(reason); 115 | }); 116 | }, 117 | 118 | then: promise.then.bind(promise), 119 | 'catch': promise['catch'].bind(promise) 120 | }; 121 | 122 | // here we handle calls to all of the middlewares 123 | function callNext(i, prevResult) { 124 | var middlewareName = undefined; 125 | // if transition has been cancelled - nothing left to do 126 | if (cancelled) { 127 | return; 128 | } 129 | // done 130 | if (i < router.middleware.length) { 131 | middlewareName = router.middleware[i].name || 'anonymous'; 132 | log('Transition #' + id, 'resolving middleware:', middlewareName); 133 | var middlewarePromise = undefined; 134 | try { 135 | middlewarePromise = router.middleware[i](transition, prevResult); 136 | (0, _invariant2['default'])(transition !== middlewarePromise, 'Middleware %s returned a transition which resulted in a deadlock', middlewareName); 137 | } catch (err) { 138 | router.state.activeTransition = null; 139 | return reject(err); 140 | } 141 | Promise.resolve(middlewarePromise).then(function (result) { 142 | callNext(i + 1, result); 143 | })['catch'](function (err) { 144 | log('Transition #' + id, 'resolving middleware:', middlewareName, 'FAILED'); 145 | router.state.activeTransition = null; 146 | reject(err); 147 | }); 148 | } else { 149 | router.state = { 150 | activeTransition: null, 151 | routes: routes, 152 | path: path, 153 | pathname: pathname, 154 | params: params, 155 | query: query 156 | }; 157 | resolve(); 158 | } 159 | } 160 | 161 | if (!options.noop) { 162 | Promise.resolve().then(function () { 163 | return callNext(0); 164 | }); 165 | } else { 166 | resolve(); 167 | } 168 | 169 | if (options.noop) { 170 | transition.noop = true; 171 | } 172 | 173 | return transition; 174 | } 175 | 176 | module.exports = exports['default']; -------------------------------------------------------------------------------- /build/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cherrytree", 3 | "version": "2.4.1", 4 | "description": "Cherrytree - a flexible hierarchical client side router", 5 | "main": "index", 6 | "scripts": { 7 | "build": "./tasks/build.sh", 8 | "release": "release --build", 9 | "test": "standard | snazzy && karma start --single-run", 10 | "test-no-coverage": "standard | snazzy && karma start --single-run --no-coverage", 11 | "watch": "DEBUG=1 webpack -w index.js build/standalone.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/QubitProducts/cherrytree.git" 16 | }, 17 | "author": "Karolis Narkevicius ", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/QubitProducts/cherrytree/issues" 21 | }, 22 | "dependencies": { 23 | "location-bar": "^2.0.0", 24 | "path-to-regexp": "^1.0.3" 25 | }, 26 | "keywords": [ 27 | "router", 28 | "history", 29 | "browser", 30 | "pushState", 31 | "hierarchical", 32 | "nested" 33 | ], 34 | "devDependencies": { 35 | "babel": "^5.8.21", 36 | "babel-core": "^5.8.22", 37 | "babel-creed-async": "^1.0.2", 38 | "babel-eslint": "^4.1.3", 39 | "babel-loader": "^5.3.2", 40 | "babel-runtime": "^5.8.20", 41 | "bro-size": "^1.0.0", 42 | "creed": "^0.7.3", 43 | "es6-promise": "^3.0.2", 44 | "istanbul-instrumenter-loader": "^0.1.3", 45 | "jquery": "^2.1.3", 46 | "karma": "^0.13.9", 47 | "karma-chrome-launcher": "^0.2.0", 48 | "karma-cli": "^0.1.0", 49 | "karma-coverage": "^0.5.0", 50 | "karma-effroi": "0.0.0", 51 | "karma-firefox-launcher": "~0.1.6", 52 | "karma-mocha": "^0.2.0", 53 | "karma-sauce-launcher": "^0.2.10", 54 | "karma-sourcemap-loader": "^0.3.4", 55 | "karma-webpack": "^1.7.0", 56 | "kn-release": "^1.0.1", 57 | "mocha": "^2.2.0", 58 | "referee": "^1.1.1", 59 | "snazzy": "^2.0.1", 60 | "standard": "^5.1.1", 61 | "webpack": "^1.4.13", 62 | "webpack-dev-server": "^1.6.6", 63 | "yargs": "^3.23.0" 64 | }, 65 | "standard": { 66 | "parser": "babel-eslint", 67 | "ignore": [ 68 | "examples/**/dist", 69 | "docs/**", 70 | "build/**" 71 | ] 72 | } 73 | } -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # Docs 2 | 3 | ### var router = cherrytree(options) 4 | 5 | * **options.log** - a function that is called with logging info, default is noop. Pass in `true`/`false` or a custom logging function. 6 | * **options.logError** - default is true. A function that is called when transitions error (except for the special `TransitionRedirected` and `TransitionCancelled` errors). Pass in `true`/`false` or a custom error handling function. 7 | * **options.pushState** - default is false, which means using hashchange events. Set to `true` to use pushState. 8 | * **options.root** - default is `/`. Use in combination with `pushState: true` if your application is not being served from the root url /. 9 | * **options.interceptLinks** - default is true. When pushState is used - intercepts all link clicks when appropriate, prevents the default behaviour and instead uses pushState to update the URL and handle the transition via the router. You can also set this option to a custom function that will get called whenever a link is clicked if you want to customize the behaviour. Read more on [intercepting links below](#intercepting-links). 10 | * **options.qs** - default is a simple built in query string parser. Pass in an object with `parse` and `stringify` functions to customize how query strings get treated. 11 | * **options.Promise** - default is window.Promise or global.Promise. Promise implementation to be used when constructing transitions. 12 | 13 | ### router.map(fn) 14 | 15 | Configure the router with a route map. E.g. 16 | 17 | ```js 18 | router.map(function (route) { 19 | route('app', {path: '/'}, function () { 20 | route('about') 21 | route('post', {path: ':postId'}, function () { 22 | route('show') 23 | route('edit') 24 | }) 25 | }) 26 | }) 27 | ``` 28 | 29 | #### Nested paths 30 | 31 | Nested paths are concatenated unless they start with a '/'. For example 32 | 33 | ```js 34 | router.map(function (route) { 35 | route('foo', {path: '/foo'}, function () { 36 | route('bar', {path: '/bar'}, function () { 37 | route('baz', {path: '/baz'}) 38 | }); 39 | }) 40 | }) 41 | ``` 42 | 43 | The above map results in 1 URL `/baz` mapping to ['foo', 'bar', 'baz'] routes. 44 | 45 | ```js 46 | router.map(function (route) { 47 | route('foo', {path: '/foo'}, function () { 48 | route('bar', {path: 'bar'}, function () { 49 | route('baz', {path: 'baz'}) 50 | }); 51 | }) 52 | }) 53 | ``` 54 | 55 | The above map results in 1 URL `/foo/bar/baz` mapping to ['foo', 'bar', 'baz'] routes. 56 | 57 | #### Dynamic paths 58 | 59 | Paths can contain dynamic segments as described in the docs of [path-to-regexp](https://github.com/pillarjs/path-to-regexp). For example: 60 | 61 | ```js 62 | route('foo', {path: '/hello/:myParam'}) // single named param, matches /hello/1 63 | route('foo', {path: '/hello/:myParam/:myOtherParam'}) // two named params, matches /hello/1/2 64 | route('foo', {path: '/hello/:myParam?'}) // single optional named param, matches /hello and /hello/1 65 | route('foo', {path: '/hello/:splat*'}) // match 0 or more segments, matches /hello and /hello/1 and /hello/1/2/3 66 | route('foo', {path: '/hello/:splat+'}) // match 1 or more segments, matches /hello/1 and /hello/1/2/3 67 | ``` 68 | 69 | #### Abstract routes 70 | 71 | By default, both leaf and non leaf routes can be navigated to. Sometimes you might not want it to be possible to navigate to certain routes at all, e.g. if the route is only used for data fetching and doesn't render anything by itself. In that case, you can set `abstract: true` in the route options. Abstract routes can still form a part of the URL. 72 | 73 | ```js 74 | router.map(function (route) { 75 | route('application', {path: '/'}, function () { 76 | route('dashboard', {path: 'dashboard/:accountId', abstract: true}, function () { 77 | route('defaultDashboard', {path: ''}) 78 | route('realtimeDashboard', {path: 'realtime'}) 79 | }); 80 | }) 81 | }) 82 | ``` 83 | 84 | Abstract routes are especially useful when creating `index` subroutes as demonstrated above. The above route map results in the following URLs: 85 | 86 | ``` 87 | / - ['application'] 88 | /dashboard/:accountId - ['application', 'dashboard', 'defaultDashboard'] 89 | /dashboard/:accountId/realtime - ['application', 'dashboard', 'realtimeDashboard'] 90 | ``` 91 | 92 | Navigating to an abstract route that has an index route is equivalent to navigating to the index route. E.g. these are equivalent: 93 | 94 | ```js 95 | router.transitionTo('dashboard') 96 | router.transitionTo('defaultDashboard') 97 | ``` 98 | 99 | Generating links is also equivalent 100 | ```js 101 | router.generate('dashboard') === router.generate('defaultDashboard') 102 | ``` 103 | 104 | However, if the abstract route does not have an index route, then it's not routable and can't have URLs generated. 105 | 106 | It's also common to redirect from non leaf routes. In this example we might want to redirect from `application` to the `defaultDashboard` route. If each of your routes are backed by some route handler object, you can achieve the redirect with the following middleware: 107 | 108 | ```js 109 | router.use(function redirect (transition) { 110 | var lastRoute = transition.routes[transition.routes.length - 1] 111 | if (lastRoute.handler.redirect) { 112 | lastRoute.handler.redirect(transition.params, transition.query) 113 | } 114 | }) 115 | ``` 116 | 117 | #### Default path 118 | 119 | If a route path is not specified, it defaults to the name of the route, e.g.: 120 | 121 | ```js 122 | route('foo') 123 | 124 | // equivalent to 125 | 126 | route('foo', {path: 'foo'}) 127 | ``` 128 | 129 | If a route has a name with dots and no path specified, the path defaults to the last segment of the path. This special "dot" behaviour might be removed in the next major version of Cherrytree. 130 | 131 | ```js 132 | route('foo.bar') 133 | 134 | // equivalent to 135 | 136 | route('foo.bar', {path: 'bar'}) 137 | ``` 138 | 139 | ### router.use(fn) 140 | 141 | Add a transition middleware. Every time a transition takes place this middleware will be called with a transition as the argument. You can call `use` multiple times to add more middlewares. The middleware function can return a promise and the next middleware will not be called until the promise of the previous middleware is resolved. The result of the promise is passed in as a second argument to the next middleware. E.g. 142 | 143 | ```js 144 | router.use(function (transition) { 145 | return Promise.all(transition.routes.map(function (route) { 146 | return route.options.handler.fetchData() 147 | })) 148 | }) 149 | 150 | router.use(function (transition, datas) { 151 | transition.routes.forEach(function (route, i) { 152 | route.options.handler.activate(datas[i]) 153 | }) 154 | }) 155 | ``` 156 | 157 | #### transition 158 | 159 | The transition object is itself a promise. It also contains the following attributes 160 | 161 | * `id` 162 | * `routes` 163 | * `path` 164 | * `pathname` 165 | * `params` 166 | * `query` 167 | * `prev` 168 | * `routes` 169 | * `path` 170 | * `pathname` 171 | * `params` 172 | * `query` 173 | 174 | And the following methods 175 | 176 | * `then` 177 | * `catch` 178 | * `cancel` 179 | * `retry` 180 | * `followRedirects` 181 | * `redirectTo` 182 | 183 | #### route 184 | 185 | During every transition, you can inspect `transition.routes` and `transition.prev.routes` to see where the router is transitioning to. These are arrays that contain a list of route descriptors. Each route descriptor has the following attributes 186 | 187 | * `name` - e.g. `'message'` 188 | * `path` - the path segment, e.g. `'message/:id'` 189 | * `params` - a list of params specifically for this route, e.g `{id: 1}` 190 | * `options` - the options object that was passed to the `route` function in the `map` 191 | 192 | ### router.listen() 193 | 194 | After the router has been configured with a route map and middleware - start listening to URL changes and transition to the appropriate route based on the current URL. 195 | 196 | When using `location: 'memory'`, the current URL is not read from the browser's location bar and instead can be passed in via listen: `listen(path)`. 197 | 198 | ### router.transitionTo(name, params, query) 199 | 200 | Transition to a route, e.g. 201 | 202 | ```js 203 | router.transitionTo('about') 204 | router.transitionTo('posts.show', {postId: 1}) 205 | router.transitionTo('posts.show', {postId: 2}, {commentId: 2}) 206 | ``` 207 | 208 | ### router.replaceWith(name, params, query) 209 | 210 | Same as transitionTo, but doesn't add an entry in browser's history, instead replaces the current entry. Useful if you don't want this transition to be accessible via browser's Back button, e.g. if you're redirecting, or if you're navigating upon clicking tabs in the UI, etc. 211 | 212 | ### router.generate(name, params, query) 213 | 214 | Generate a URL for a route, e.g. 215 | 216 | ```js 217 | router.generate('about') 218 | router.generate('posts.show', {postId: 1}) 219 | router.generate('posts.show', {postId: 2}, {commentId: 2}) 220 | ``` 221 | 222 | It generates a URL with # if router is in hashChange mode and with no # if router is in pushState mode. 223 | 224 | ### router.isActive(name, params, query) 225 | 226 | Check if a given route, params and query is active. 227 | 228 | ```js 229 | router.isActive('status') 230 | router.isActive('status', {user: 'me'}) 231 | router.isActive('status', {user: 'me'}, {commentId: 2}) 232 | router.isActive('status', null, {commentId: 2}) 233 | ``` 234 | 235 | ### router.state 236 | 237 | The state of the route is always available on the `router.state` object. It contains `activeTransition`, `routes`, `path`, `pathname`, `params` and `query`. 238 | 239 | ### router.matchers 240 | 241 | Use this to inspect all the routes and their URL patterns that exist in your application. It's an array of: 242 | 243 | ```js 244 | { 245 | name, 246 | path, 247 | routes 248 | } 249 | ``` 250 | 251 | listed in the order that they will be matched against the URL. 252 | 253 | ## Query params 254 | 255 | Cherrytree will extract and parse the query params using a very simple query string parser that only supports key values. For example, `?a=1&b=2` will be parsed to `{a: 1, b:2}`. If you want to use a more sophisticated query parser, pass in an object with `parse` and `stringify` functions - an interface compatible with the popular [qs](https://github.com/hapijs/qs) module e.g.: 256 | 257 | ```js 258 | cherrytree({ 259 | qs: require('qs') 260 | }) 261 | ``` 262 | 263 | 264 | ## Errors 265 | 266 | Transitions can fail, in which case the transition promise is rejected with the error object. This could happen, for example, if some middleware throws or returns a rejected promise. 267 | 268 | There are also two special errors that can be thrown when a redirect happens or when transition is cancelled completely. 269 | 270 | In case of redirect (someone initiating a router.transitionTo() while another transition was active) and error object will have a `type` attribute set to 'TransitionRedirected' and `nextPath` attribute set to the path of the new transition. 271 | 272 | In case of cancelling (someone calling transition.cancel()) the error object will have a `type` attribute set to 'TransitionCancelled'. 273 | 274 | If you have some error handling middleware - you most likely want to check for these two special errors, because they're normal to the functioning of the router, it's common to perform redirects. 275 | 276 | ## BrowserLocation 277 | 278 | Cherrytree can be configured to use differet implementations of libraries that manage browser's URL/history. By default, Cherrytree will use a very versatile implementation - `cherrytree/lib/locations/browser` which supports `pushState` and `hashChange` based URL management with graceful fallback of `pushState` -> `hashChange` -> `polling` depending on browser's capabilities. 279 | 280 | Configure BrowserLocation by passing options directly to the router. 281 | 282 | ```js 283 | var router = cherrytree({ 284 | pushState: true 285 | }) 286 | ``` 287 | 288 | * options.pushState - default is false, which means using hashchange events. Set to true to use pushState. 289 | * options.root - default is `/`. Use in combination with `pushState: true` if your application is not being served from the root url /. 290 | 291 | ## MemoryLocation 292 | 293 | MemoryLocation can be used if you don't want router to touch the address bar at all. Navigating around the application will only be possible programatically by calling `router.transitionTo` and similar methods. 294 | 295 | e.g. 296 | 297 | ```js 298 | var router = cherrytree({ 299 | location: 'memory' 300 | }) 301 | ``` 302 | 303 | ## CustomLocation 304 | 305 | You can also pass a custom location in explicitly. This is an advanced use case, but might turn out to be useful in non browser environments. For this you'll need to investigate how BrowserLocation is implemented. 306 | 307 | ```js 308 | var router = cherrytree({ 309 | location: myCustomLocation() 310 | }) 311 | ``` 312 | 313 | 314 | ## Intercepting Links 315 | 316 | Cherrytree intercepts all link clicks when using pushState, because without this functionality - the browser would just do a full page refresh on every click of a link. 317 | 318 | The clicks **are** intercepted only if: 319 | 320 | * router is passed a `interceptLinks: true` (default) 321 | * the currently used location and browser supports pushState 322 | * clicked with the left mouse button with no cmd or shift key 323 | 324 | The clicks that **are never** intercepted: 325 | 326 | * external links 327 | * `javascript:` links 328 | * `mailto:` links 329 | * links with a `data-bypass` attribute 330 | * links starting with `#` 331 | 332 | The default implementation of the intercept click handler is: 333 | 334 | ```js 335 | function defaultClickHandler (event, link, router) { 336 | event.preventDefault() 337 | router.transitionTo(router.location.removeRoot(link.getAttribute('href'))) 338 | } 339 | ``` 340 | 341 | You can pass in a custom function as the `interceptLinks` router option to customize this behaviour. E.g. to use `replaceWith` instead of `transitionTo`. 342 | 343 | 344 | ## Handling 404 345 | 346 | There are a couple of ways to handle URLs that don't match any routes. 347 | 348 | You can create a middleware to detects when `transition.routes.length` is 0 and render a 404 page. 349 | 350 | Alternatively, you can also declare a catch all path in your route map: 351 | 352 | ```js 353 | router.map(function (route) { 354 | route('application', {path: '/'}, function () { 355 | route('blog') 356 | route('missing', {path: ':path*'}) 357 | }) 358 | }) 359 | ``` 360 | 361 | In this case, when nothing else matches, a transition to the `missing` route will be initiated with `transition.routes` as ['application', 'missing']. This gives you a chance to activate and render the `application` route before rendering a 404 page. 362 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # Cherrytree guide 2 | 3 | When your application starts, the router is responsible for loading data, rendering views and otherwise setting up application state. It does so by translating every URL change to a transition object and a list of matching routes. You then need to apply a middleware function to translate the transition data into the desired state of your application. 4 | 5 | First create an instance of the router. 6 | 7 | ```js 8 | var cherrytree = require("cherrytree"); 9 | var router = cherrytree({ 10 | pushState: true 11 | }); 12 | ``` 13 | 14 | Then use the `map` method to declare the route map. 15 | 16 | ```js 17 | router.map(function (route) { 18 | route('application', { path: '/', abstract: true, handler: App }, function () { 19 | route('index', { path: '', handler: Index }) 20 | route('about', { handler: About }) 21 | route('favorites', { path: 'favs', handler: Favorites }) 22 | route('message', { path: 'message/:id', handler: Message }) 23 | }) 24 | }); 25 | ``` 26 | 27 | Next, install middleware. 28 | 29 | ```js 30 | router.use(function activate (transition) { 31 | transition.routes.forEach(function (route) { 32 | route.options.handler.activate(transition.params, transition.query) 33 | }) 34 | }) 35 | ``` 36 | 37 | Now, when the user enters `/about` page, Cherrytree will call the middleware with the transition object and `transition.routes` will be the route descriptors of `application` and `about` routes. 38 | 39 | Note that you can leave off the path if you want to use the route name as the path. For example, these are equivalent 40 | 41 | ```js 42 | router.map(function(route) { 43 | route('about'); 44 | }); 45 | 46 | // or 47 | 48 | router.map(function(route) { 49 | route('about', {path: 'about'}); 50 | }); 51 | ``` 52 | 53 | To generate links to the different routes use `generate` and pass the name of the route: 54 | 55 | ```js 56 | router.generate('favorites') 57 | // => /favs 58 | router.generate('index'); 59 | // => / 60 | router.generate('messages', {id: 24}); 61 | ``` 62 | 63 | If you disable pushState (`pushState: false`), the generated links will start with `#`. 64 | 65 | ### Route params 66 | 67 | Routes can have dynamic urls by specifying patterns in the `path` option. For example: 68 | 69 | ```js 70 | router.map(function(route) { 71 | route('posts'); 72 | route('post', { path: '/post/:postId' }); 73 | }); 74 | 75 | router.use(function (transition) { 76 | console.log(transition.params) 77 | // => {postId: 5} 78 | }); 79 | 80 | router.transitionTo('/post/5') 81 | ``` 82 | 83 | See what other types of dynamic routes is supported in the [api docs](api.md#dynamic-paths). 84 | 85 | ### Route Nesting 86 | 87 | Route nesting is one of the core features of cherrytree. It's useful to nest routes when you want to configure each route to perform a different role in rendering the page - e.g. the root `application` route can do some initial data loading/initialization, but you can avoid redoing that work on subsequent transitions by checking if the route is already in the middleware. The nested routes can then load data specific for a given page. Nesting routes is also very useful for rendering nested UIs, e.g. if you're building an email application, you might have the following route map 88 | 89 | ```js 90 | router.map(function(route) { 91 | route('gmail', {path: '/', abstract: true}, function () { 92 | route('inbox', {path: ''}, function () { 93 | route('email', {path: 'm/:emailId'}, function () { 94 | route('email.raw') 95 | }) 96 | }) 97 | }) 98 | }) 99 | ``` 100 | 101 | This router creates the following routes: 102 | 103 |
104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 |
URLRoute NamePurpose
N/AgmailCan't route to it, it's an abstract route.
/inboxLoad 1 page of emails and render it.
/m/:emailId/emailLoad the email contents of email with id `transition.params.emailId` and expand it in the list of emails while keeping the email list rendered.
/m/:mailId/rawemail.rawRender the raw textual version of the email in an expanded pane.
133 |
134 | 135 | ## Examples 136 | 137 | I hope you found this brief guide useful, check out some example apps next in the [examples](../examples) dir. 138 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | $ git clone git@github.com:QubitProducts/cherrytree.git 4 | $ cd cherrytre/examples/cherry-pick 5 | $ npm install 6 | $ npm start 7 | $ open http://localhost:8000 8 | -------------------------------------------------------------------------------- /examples/cherry-pick/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /examples/cherry-pick/README.md: -------------------------------------------------------------------------------- 1 | # cherry-pick 2 | 3 | A mini GitHub clone demonstrating how to use [Cherrytree](https://github.com/QubitProducts/cherrytree) router with React.js. 4 | 5 | It's work in progress, this is an initial version. 6 | 7 | # Run locally 8 | 9 | ``` 10 | npm install 11 | npm start 12 | ``` 13 | 14 | Now open [http://localhost:8000](http://localhost:8000). 15 | 16 | Or open [http://localhost:8000/webpack-dev-server/bundle](http://localhost:8000/webpack-dev-server/bundle) to see the live reloading version of the app. 17 | 18 | # TODO 19 | 20 | - [ ] add a changelog tab that compiles changelog from commits + tags, filters merge commits 21 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/app.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import when from 'when' 3 | import keys from 'when/keys' 4 | import React from 'react' 5 | import cherrytree from 'cherrytree' 6 | import loader from './loader' 7 | 8 | let router = window.router = cherrytree({ 9 | log: true 10 | }) 11 | 12 | router.map(function (route) { 13 | route('application', {path: '/', abstract: true}, function () { 14 | route('index', {path: ''}) 15 | route('organisation', {path: ':org'}) 16 | route('repo', {path: ':org/:repo'}, function () { 17 | route('repo.code', {path: 'code/:path*'}) 18 | route('repo.commits') 19 | }) 20 | }) 21 | }) 22 | 23 | // install a global loading animation 24 | router.use(loader) 25 | 26 | // load route handlers 27 | router.use((transition) => { 28 | transition.routes.forEach( 29 | (route) => route.RouteHandler = route.RouteHandler || getRouteHandler(route, transition.routes) 30 | ) 31 | }) 32 | 33 | // load data (or context) for each route 34 | router.use((transition) => { 35 | return when.all(transition.routes.map((route) => { 36 | if (route.RouteHandler.fetchData) { 37 | return keys.all(route.RouteHandler.fetchData(transition.params, transition)) 38 | } 39 | })) 40 | }) 41 | 42 | // render 43 | router.use((transition, contexts) => { 44 | // use React context feature to pass in router into every 45 | // React component in this app, so that they could use it to 46 | // generate links and initiate transitions. 47 | // (Not to be confused with the context data for each route 48 | // that we loaded using fetchData and pass into each RouteHandler 49 | // as props) 50 | React.withContext({router: router}, () => { 51 | let childRouteHandler 52 | // start rendering with the child most route first 53 | // working our way up to the parent 54 | let i = transition.routes.length - 1 55 | _.clone(transition.routes).reverse().forEach((route) => { 56 | let RouteHandler = route.RouteHandler 57 | let context = contexts[i--] 58 | childRouteHandler = {childRouteHandler} 59 | }) 60 | // when we finish the loop above, childRouteHandler 61 | // contains the top most route, which is the application 62 | // route. Let's render that into the page 63 | var app = childRouteHandler 64 | React.render(app, document.body) 65 | }) 66 | }) 67 | 68 | // kick off the routing 69 | router.listen() 70 | 71 | // This is a webpack specific way of automatically 72 | // loading the route file for each route. We construct the path 73 | // to the filename based on route.ancestors and route.name. 74 | // e.g. for route 'repo.commits' which has the following route hierarchy 75 | // ['application', 'repo', 'repo.commits'], we construct a filename 76 | // ./screens/application/screens/repo/screens/commits/index 77 | // 78 | // Alternatively we could just require each file one by one manually and key by the 79 | // route name in an object, e.g. 80 | // { 'repo.commits': require('./screens/application/screens/repo/screens/commits/index')} 81 | // 82 | // We could also load the routes asynchronously here if we wanted to split the app 83 | // into multiple bundles based on routes. 84 | function getRouteHandler (route, routes) { 85 | let ancestors = [] 86 | routes.find(function (r) { 87 | if (r.name === route.name) { 88 | return true 89 | } 90 | ancestors.push(r.name) 91 | }) 92 | let path = ancestors.concat(route.name) 93 | let normalizedPath = path.map((a) => a.includes('.') ? a.split('.')[1] : a) 94 | var req = require.context('./', true, /^\.(\/screens\/[^\/]*)+\/index$/) 95 | return req('./screens/' + normalizedPath.join('/screens/') + '/index') 96 | } 97 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/loader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a middleware for cherrytree. 3 | * It renders a global loading animation for async transitions. 4 | */ 5 | 6 | import 'nprogress/nprogress.css' 7 | import NProgress from 'nprogress' 8 | 9 | let loaderTimeout 10 | 11 | export default function loading (transition) { 12 | if (!loaderTimeout) { 13 | loaderTimeout = setTimeout(startAnimation, 200) 14 | } 15 | 16 | transition.then(stopAnimation).catch(function (err) { 17 | if (err.type !== 'TransitionRedirected') { 18 | stopAnimation() 19 | } 20 | // don't swallow the error 21 | throw err 22 | }) 23 | } 24 | 25 | function startAnimation () { 26 | NProgress.start() 27 | } 28 | 29 | function stopAnimation () { 30 | clearTimeout(loaderTimeout) 31 | loaderTimeout = null 32 | NProgress.done() 33 | } 34 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/application.css: -------------------------------------------------------------------------------- 1 | .Application { 2 | display: flex; 3 | min-height: 100vh; 4 | flex-direction: column; 5 | } 6 | 7 | .Application-content { 8 | flex: 1; 9 | } 10 | 11 | .Navbar { 12 | background: #fcd68a; 13 | padding: 12px; 14 | border: none; 15 | margin: 0; 16 | } 17 | 18 | .Navbar-header { 19 | text-align: center; 20 | float: none; 21 | } 22 | 23 | .Navbar-brand { 24 | display: inline-block; 25 | height: auto; 26 | float: none; 27 | font-weight: bold; 28 | width: 32px; 29 | height: 32px; 30 | background: url('cherry.png'); 31 | } 32 | 33 | .Footer { 34 | color: #aaa; 35 | border-top: 1px solid #eee; 36 | padding: 30px; 37 | margin-top: 40px; 38 | } 39 | 40 | #nprogress .spinner { 41 | margin-top: 5px; 42 | margin-right: 5px; 43 | } -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/base.css: -------------------------------------------------------------------------------- 1 | @import url('http://fonts.googleapis.com/css?family=Lato:300,400,700'); 2 | @import url(http://fonts.googleapis.com/css?family=PT+Mono); 3 | 4 | body { 5 | font-family: 'Lato', sans-serif; 6 | font-weight: lighter; 7 | } 8 | 9 | input[type="text"] { 10 | border: 1px solid #aaa; 11 | } 12 | 13 | input[type="text"]:focus { 14 | border: 1px solid #0036ff; 15 | box-shadow: inset 0 1px rgba(255,255,255,0.36), 0 0 0 2px #6fb5f1; 16 | outline: 0; 17 | } 18 | 19 | a, a:visited { 20 | color: #005AA8; 21 | } -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/cherry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QubitProducts/cherrytree/23bcc7a9f625e1eb4beca7f089ac6291738bc4e6/examples/cherry-pick/app/screens/application/cherry.png -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/index.js: -------------------------------------------------------------------------------- 1 | import './base.css' 2 | import './application.css' 3 | import 'suitcss-base' 4 | import 'suitcss-utils-text' 5 | import 'suitcss-components-arrange' 6 | import React from 'react' 7 | 8 | module.exports = React.createClass({ 9 | propTypes: { 10 | children: React.PropTypes.any 11 | }, 12 | 13 | contextTypes: { 14 | router: React.PropTypes.object.isRequired 15 | }, 16 | 17 | link () { 18 | var router = this.context.router 19 | return router.generate.apply(router, arguments) 20 | }, 21 | 22 | getInitialState () { 23 | var time = new Date().getTime() 24 | // setInterval(this.updateTime, 1000) 25 | return { time } 26 | }, 27 | 28 | updateTime () { 29 | var time = new Date().getTime() 30 | this.setState({time}) 31 | }, 32 | 33 | render: function () { 34 | return ( 35 |
36 |
37 |
38 | 39 |
40 |
41 | 42 |
43 | {this.props.children} 44 |
45 | 46 | 52 |
53 | ) 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/screens/index/index.css: -------------------------------------------------------------------------------- 1 | .IndexPage { 2 | text-align: center; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | min-height: 50vh; 7 | } 8 | 9 | .IndexPage-description { 10 | text-align: center; 11 | margin: 40px; 12 | } 13 | 14 | .IndexPage-repoInput { 15 | width: 400px; 16 | padding: 10px; 17 | font-size: 24px; 18 | text-align: center; 19 | } 20 | 21 | .IndexPage-repoInput[disabled] { 22 | color: #888; 23 | background: #eee; 24 | } -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/screens/index/index.js: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | import React from 'react' 3 | 4 | export default React.createClass({ 5 | 6 | contextTypes: { 7 | router: React.PropTypes.object.isRequired 8 | }, 9 | 10 | getInitialState () { 11 | return {value: 'KidkArolis/cherry-pick'} 12 | }, 13 | 14 | handleChange (event) { 15 | this.setState({value: event.target.value}) 16 | }, 17 | 18 | handleSubmit (event) { 19 | event.preventDefault() 20 | var repo = this.refs.repoInput.getDOMNode().value.split('/') 21 | this.setState({disabled: true}) 22 | this.context.router.transitionTo('repo.commits', {org: repo[0], repo: repo[1]}) 23 | }, 24 | 25 | render () { 26 | var value = this.state.value 27 | var disabled = this.state.disabled 28 | return ( 29 |
30 |
31 |

32 | Cherry-pick is a demo app showing how to use Cherrytree router with React.js 33 |

34 |

Enter a GitHub repo

35 |
36 | 44 |
45 |
46 |
47 | ) 48 | }, 49 | 50 | componentDidMount () { 51 | var repoInput = this.refs.repoInput.getDOMNode() 52 | repoInput.focus() 53 | repoInput.select() 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/screens/repo/index.js: -------------------------------------------------------------------------------- 1 | import './repo.css' 2 | import 'suitcss-utils-layout' 3 | import React from 'react' 4 | 5 | export default React.createClass({ 6 | contextTypes: { 7 | router: React.PropTypes.object.isRequired 8 | }, 9 | 10 | propTypes: { 11 | org: React.PropTypes.string, 12 | repo: React.PropTypes.string, 13 | children: React.PropTypes.any 14 | }, 15 | 16 | render: function () { 17 | return ( 18 |
19 |
20 |
    21 |
  • 22 | {this.props.org} / {this.props.repo} 23 |
  • 24 |
  • 25 | Code 26 |
  • 27 |
  • 28 | Commits 29 |
  • 30 |
31 |
32 |
{this.props.children}
33 |
34 | ) 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/screens/repo/repo.css: -------------------------------------------------------------------------------- 1 | .RepoHeader { 2 | margin: 20px 100px 0; 3 | } 4 | 5 | .RepoHeader-navItem { 6 | padding: 4px; 7 | margin: 6px; 8 | } -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/screens/repo/screens/code/code.css: -------------------------------------------------------------------------------- 1 | .Code { 2 | list-style: none; 3 | width: 800px; 4 | margin: auto; 5 | } 6 | 7 | .Code-breadcrumb { 8 | font-family: 'PT Mono'; 9 | padding: 4px; 10 | margin: 4px; 11 | font-size: 12px; 12 | border-bottom: 1px solid #eee; 13 | } 14 | 15 | .Code-breadcrumbPart { 16 | display: inline-block; 17 | padding: 0 2px; 18 | } 19 | 20 | .Code-file { 21 | font-family: 'PT Mono'; 22 | font-size: 12px; 23 | margin: 4px; 24 | border-bottom: 1px solid #eee; 25 | } 26 | 27 | .Code-fileLink { 28 | padding: 4px; 29 | display: block; 30 | } 31 | 32 | .Code-fileContents { 33 | font-family: 'PT Mono'; 34 | font-size: 12px; 35 | border: 1px solid #eee; 36 | border-top: none; 37 | border-radius: 2px; 38 | padding: 10px; 39 | } -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/screens/repo/screens/code/index.js: -------------------------------------------------------------------------------- 1 | import './code.css' 2 | import React from 'react' 3 | import R from 'ramda' 4 | import * as github from 'github' 5 | let decode = window.atob 6 | 7 | let Breadcrumb = React.createClass({ 8 | render () { 9 | var props = this.props 10 | return ( 11 |
    12 |
  • 13 | {props.repo} 14 |
  • 15 | {slash()} 16 | {interleaveSlashes(parts(props.path, props.link))} 17 |
18 | ) 19 | 20 | function parts (path, link) { 21 | var p = '' 22 | return path.split('/').map(function (part) { 23 | if (p !== '') { 24 | p += '/' 25 | } 26 | p += part 27 | return ( 28 |
  • 29 | {part} 30 |
  • 31 | ) 32 | }) 33 | } 34 | 35 | function interleaveSlashes (parts) { 36 | return parts.reduce(function (memo, part) { 37 | memo.push(part) 38 | if (memo.length !== (parts.length * 2) - 1) { 39 | memo.push(slash()) 40 | } 41 | return memo 42 | }, []) 43 | } 44 | 45 | function slash () { 46 | return
  • {'/'}
  • 47 | } 48 | } 49 | }) 50 | 51 | export default React.createClass({ 52 | contextTypes: { 53 | router: React.PropTypes.object.isRequired 54 | }, 55 | 56 | propTypes: { 57 | code: React.PropTypes.object, 58 | path: React.PropTypes.string, 59 | repo: React.PropTypes.string 60 | }, 61 | 62 | statics: { 63 | fetchData: function (params) { 64 | var repoUid = params.org + '/' + params.repo 65 | var path = params.path || '' 66 | return { 67 | code: github.code(repoUid, path), 68 | path: path, 69 | repo: params.repo 70 | } 71 | } 72 | }, 73 | render () { 74 | var code = this.props.code 75 | var path = this.props.path 76 | var repo = this.props.repo 77 | 78 | var content 79 | if (code.type && code.type === 'file') { 80 | content = this.renderFile(code) 81 | } else { 82 | content = this.renderTree(code) 83 | } 84 | 85 | return ( 86 |
    87 | 88 | {content} 89 |
    90 | ) 91 | }, 92 | 93 | renderTree (list) { 94 | var files = R.map((item) => { 95 | return ( 96 |
  • 97 | {item.name} 98 |
  • 99 | ) 100 | }, list) 101 | return (
      {files}
    ) 102 | }, 103 | 104 | renderFile (file) { 105 | return (
    {decode(file.content)}
    ) 106 | }, 107 | 108 | link (path) { 109 | return this.context.router.generate('repo.code', { 110 | path: path 111 | }) 112 | } 113 | }) 114 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/screens/repo/screens/commits/commits.css: -------------------------------------------------------------------------------- 1 | .Commits { 2 | padding: 50px 100px; 3 | list-style: none; 4 | } 5 | .Commits-commit { 6 | font-family: 'PT Mono'; 7 | font-size: 12px; 8 | margin-top: 6px; 9 | padding-bottom: 7px; 10 | border-bottom: 1px solid #f2f2f2; 11 | } 12 | .Commits-commit:last-child { 13 | border-bottom: none; 14 | } -------------------------------------------------------------------------------- /examples/cherry-pick/app/screens/application/screens/repo/screens/commits/index.js: -------------------------------------------------------------------------------- 1 | import './commits.css' 2 | import React from 'react' 3 | import R from 'ramda' 4 | import * as github from 'github' 5 | 6 | export default React.createClass({ 7 | statics: { 8 | fetchData: function (params) { 9 | return { 10 | commits: github.commits(params.org + '/' + params.repo) 11 | } 12 | } 13 | }, 14 | 15 | propTypes: { 16 | commits: React.PropTypes.array 17 | }, 18 | 19 | render () { 20 | return ( 21 |
      22 | {this.commits()(this.props.commits || [])} 23 |
    24 | ) 25 | }, 26 | 27 | commits () { 28 | return R.map((commit) => 29 |
  • {commit.commit.message}
  • 30 | ) 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/shared/github.js: -------------------------------------------------------------------------------- 1 | import rest from 'rest' 2 | import mime from 'rest/interceptor/mime' 3 | 4 | var client = rest.wrap(mime) 5 | 6 | function get (path) { 7 | return client({ 8 | path: 'https://api.github.com/' + path 9 | // headers: { 10 | // 'Authorization': 'token ' + token 11 | // } 12 | }).then(response => response.entity) 13 | } 14 | 15 | export function commits (repo) { 16 | return get(`repos/${repo}/commits`) 17 | } 18 | 19 | export function code (repo, path, sha) { 20 | sha = sha || 'master' 21 | path = path || '' 22 | if (path[0] === '/') { 23 | path = path.slice(1) 24 | } 25 | // return get('repos/' + repo + '/git/trees/' + sha) 26 | return get(`repos/${repo}/contents/${path}`) 27 | } 28 | -------------------------------------------------------------------------------- /examples/cherry-pick/app/shared/react-route.js: -------------------------------------------------------------------------------- 1 | import R from 'ramda' 2 | import React from 'react' 3 | import Route from 'cherrytree/route' 4 | 5 | module.exports = Route.extend({ 6 | 7 | rootEl: document.body, 8 | 9 | initialize: function () { 10 | if (this.componentClass) { 11 | this.componentFactory = React.createFactory(this.componentClass) 12 | } 13 | }, 14 | 15 | activate: function (context) { 16 | this.render(context) 17 | this.afterActivate() 18 | }, 19 | 20 | afterActivate: function () {}, 21 | 22 | deactivate: function () { 23 | React.unmountComponentAtNode(this.targetEl()) 24 | }, 25 | 26 | createComponent: function (context) { 27 | if (this.componentFactory) { 28 | var props = R.mixin(context || {}, { 29 | router: this.router 30 | }) 31 | return this.componentFactory(props, this.children) 32 | } 33 | }, 34 | 35 | targetEl: function () { 36 | return this.parent ? this.parent.outletEl : this.rootEl 37 | }, 38 | 39 | render: function (context) { 40 | this.component = this.createComponent(context) 41 | 42 | var targetEl = this.targetEl() 43 | if (this.component) { 44 | var c = React.render(this.component, targetEl) 45 | this.outletEl = c.getDOMNode().querySelectorAll('.outlet')[0] 46 | } else { 47 | this.outletEl = targetEl 48 | } 49 | }, 50 | 51 | rerenderParents: function () { 52 | if (this.parent && this.parent.render) { 53 | var component = this.component || this.children 54 | this.parent.children = component 55 | this.parent.render(this.parent.getContext()) 56 | } 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /examples/cherry-pick/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/cherry-pick/index.js: -------------------------------------------------------------------------------- 1 | import './app/app' 2 | -------------------------------------------------------------------------------- /examples/cherry-pick/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cherry-pick", 3 | "version": "1.0.0", 4 | "description": "A demo app for Cherrytree router", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --colors --no-info --port=8000 --content-base .", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "css-loader": "^0.9.0", 14 | "file-loader": "^0.8.1", 15 | "react": "^0.12.2", 16 | "rework-webpack-loader": "0.0.0", 17 | "style-loader": "^0.7.1", 18 | "url-loader": "^0.5.5", 19 | "webpack": "^1.3.0", 20 | "webpack-dev-server": "^1.6.4", 21 | "webpack-traceur-loader": "^0.3.0" 22 | }, 23 | "dependencies": { 24 | "babel-core": "^4.4.5", 25 | "babel-loader": "^4.0.0", 26 | "cherrytree": "file:../..", 27 | "lodash": "^3.3.0", 28 | "nprogress": "^0.1.6", 29 | "ramda": "^0.8.0", 30 | "randomcolor": "^0.1.1", 31 | "react": "^0.12.2", 32 | "rest": "git://github.com/cujojs/rest#dev", 33 | "rework-webpack-loader": "^0.1.0", 34 | "suitcss-base": "^0.8.0", 35 | "suitcss-components-arrange": "^0.6.2", 36 | "suitcss-utils-layout": "^0.4.2", 37 | "suitcss-utils-text": "^0.4.2", 38 | "when": "^3.7.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/cherry-pick/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var reworkLoader = require('rework-webpack-loader') 3 | 4 | module.exports = { 5 | context: __dirname, 6 | entry: './index', 7 | output: { 8 | path: 'dist', 9 | filename: 'bundle.js' 10 | }, 11 | devtool: 'inline-source-map', 12 | resolve: { 13 | modulesDirectories: ['node_modules', 'shared'] 14 | }, 15 | plugins: [ 16 | new webpack.DefinePlugin({'process.env.NODE_ENV': '"development"'}) 17 | ], 18 | module: { 19 | loaders: [ 20 | { test: /.*\.js$/, exclude: /node_modules/, loader: 'babel' }, 21 | { test: /.*node_modules\/cherrytree\/.*\.js$/, loader: 'babel' }, 22 | { test: /\.css$/, loader: 'style!rework-webpack' }, 23 | { test: /\.woff$/, loader: 'url-loader?limit=10000&minetype=application/font-woff' }, 24 | { test: /\.png$/, loader: 'file-loader?mimetype=image/png' }, 25 | { test: /\.ttf$/, loader: 'file-loader' }, 26 | { test: /\.eot$/, loader: 'file-loader' }, 27 | { test: /\.svg$/, loader: 'file-loader' } 28 | ] 29 | }, 30 | rework: { 31 | use: [ 32 | reworkLoader.plugins.imports, 33 | reworkLoader.plugins.urls 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/hello-world-jquery/README.md: -------------------------------------------------------------------------------- 1 | # Example: hello world 2 | 3 | This is a single file demo of cherrytree. It's a very simple static twitter like app. It's simple to keep the code short and just show how to get started. 4 | 5 | $ npm install 6 | $ npm start 7 | 8 | Now open [http://localhost:8000](http://localhost:8000). 9 | 10 | Or open [http://localhost:8000/webpack-dev-server/bundle](http://localhost:8000/webpack-dev-server/bundle) to see the live reloading version of the app. 11 | 12 | To compile the app into a single file for production run 13 | 14 | $ npm run bundle 15 | -------------------------------------------------------------------------------- /examples/hello-world-jquery/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Cherrytree Hello World 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/hello-world-jquery/index.js: -------------------------------------------------------------------------------- 1 | let $ = require('jquery') 2 | let cherrytree = require('cherrytree') 3 | 4 | require('./style.css') 5 | 6 | // create the router 7 | let router = cherrytree({ 8 | log: true 9 | }) 10 | 11 | // create some handlers 12 | let application = { 13 | activate: function () { 14 | this.view = $(` 15 |
    16 |
    17 |

    Application

    18 | 23 |
    24 |
    25 |
    26 | `) 27 | } 28 | } 29 | 30 | let home = { 31 | activate: function () { 32 | this.view = $(` 33 |
    34 |

    Tweets

    35 |
    36 | 39 |
    12m12 minutes ago
    40 |
    Another use case for \`this.context\` I think might be valid: forms. They're too painful right now.
    41 |
    42 |
    43 | 46 |
    12m12 minutes ago
    47 |
    I just published “What will Datasmoothie bring to the analytics startup landscape?” https://medium.com/@afanasjevas/what-will-datasmoothie-bring-to-the-analytics-startup-landscape-f7dab70d75c3?source=tw-81c4e81fe6f8-1427630532296
    48 |
    49 |
    50 |
    51 | LNUG ‏@LNUGorg 52 |
    53 |
    52m52 minutes ago
    54 |
    new talks uploaded on our YouTube page - check them out http://bit.ly/1yoXSAO
    55 |
    56 |
    57 | `) 58 | } 59 | } 60 | 61 | let messages = { 62 | activate: function () { 63 | this.view = $(` 64 |
    65 |

    Messages

    66 |

    You have no direct messages

    67 |
    68 | `) 69 | } 70 | } 71 | 72 | let profile = { 73 | activate: function () { 74 | this.view = $(` 75 |
    76 |
    77 |
    78 | `) 79 | } 80 | } 81 | 82 | let profileIndex = { 83 | activate: function (params) { 84 | this.view = $(` 85 |
    86 |

    ${params.user} profile

    87 |
    88 | `) 89 | } 90 | } 91 | 92 | // provide your route map 93 | // in this particular case we configure handlers by attaching 94 | // them to routes via options. This is one of several ways you 95 | // could choose to handle transitions in your app. 96 | // * you can attach handlers to the route options like here 97 | // * you could get the route handlers of some map somewhere by name 98 | // * you can have a dynamic require that pulls in the route from a file by name 99 | router.map((route) => { 100 | route('application', {path: '/', handler: application, abstract: true}, () => { 101 | route('home', {path: '', handler: home}) 102 | route('messages', {handler: messages}) 103 | route('status', {path: ':user/status/:id'}) 104 | route('profile', {path: ':user', handler: profile, abstract: true}, () => { 105 | route('profile.index', {path: '', handler: profileIndex}) 106 | route('profile.lists') 107 | route('profile.edit') 108 | }) 109 | }) 110 | }) 111 | 112 | // install middleware that will handle transitions 113 | router.use(function activate (transition) { 114 | transition.routes.forEach((route, i) => { 115 | let handler = route.options.handler 116 | router.log(`Transition #${transition.id} activating '${route.name}'`) 117 | handler.activate(transition.params) 118 | if (handler.view) { 119 | let parent = transition.routes[i - 1] 120 | let $container = parent ? parent.options.handler.view.find('.Container') : $(document.body) 121 | $container.html(handler.view) 122 | } 123 | }) 124 | }) 125 | 126 | // start listening to browser's location bar changes 127 | router.listen() 128 | -------------------------------------------------------------------------------- /examples/hello-world-jquery/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello-world", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --colors --no-info --port=8000 --content-base .", 8 | "bundle": "webpack --progress --colors -p", 9 | "test": "mocha" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "babel-core": "^5.8.20", 15 | "babel-loader": "^5.3.2", 16 | "style-loader": "^0.8.3", 17 | "webpack": "^1.5.3", 18 | "webpack-dev-server": "^1.7.0" 19 | }, 20 | "dependencies": { 21 | "cherrytree": "file:../..", 22 | "css-loader": "^0.9.1", 23 | "jquery": "^2.1.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/hello-world-jquery/style.css: -------------------------------------------------------------------------------- 1 | @import url(http://fonts.googleapis.com/css?family=Open+Sans:400,700); 2 | 3 | body, html { 4 | margin: 0; 5 | padding: 0; 6 | font-family: 'Open Sans', sans-serif; 7 | } 8 | 9 | .App { 10 | width: 800px; 11 | margin: 0 auto 20px auto; 12 | } 13 | 14 | .App-header { 15 | border-bottom: 1px solid #eee; 16 | } 17 | 18 | .App h1 { 19 | display: inline-block; 20 | } 21 | 22 | .Nav { 23 | display: inline-block; 24 | } 25 | 26 | .Nav-item { 27 | list-style: none; 28 | display: inline-block; 29 | } 30 | 31 | .Nav-item a { 32 | padding: 10px; 33 | } 34 | 35 | .Tweet { 36 | border: 1px solid #eee; 37 | border-radius: 3px; 38 | padding: 10px; 39 | border-bottom: none; 40 | } 41 | 42 | .Tweet:last-child { 43 | border-bottom: 1px solid #eee; 44 | } 45 | 46 | .Tweet-author { 47 | font-weight: bold; 48 | display: inline-block; 49 | } 50 | 51 | .Tweet-time { 52 | color: #888; 53 | display: inline-block; 54 | margin-left: 20px; 55 | font-size: 12px; 56 | } -------------------------------------------------------------------------------- /examples/hello-world-jquery/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | context: __dirname, 3 | entry: './index', 4 | output: { 5 | path: 'dist', 6 | filename: 'bundle.js' 7 | }, 8 | devtool: 'source-map', 9 | module: { 10 | loaders: [ 11 | { test: /.*\.js$/, exclude: /node_modules/, loader: 'babel' }, 12 | { test: /.*node_modules\/cherrytree\/.*\.js$/, loader: 'babel' }, 13 | { test: /\.css$/, loader: 'style!css' } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/hello-world-react/README.md: -------------------------------------------------------------------------------- 1 | # Example: hello world 2 | 3 | This is a single file demo of cherrytree. It's a very simple static twitter like app. It's simple to keep the code short and just show how to get started. 4 | 5 | $ npm install 6 | $ npm start 7 | 8 | Now open [http://localhost:8000](http://localhost:8000). 9 | 10 | Or open [http://localhost:8000/webpack-dev-server/bundle](http://localhost:8000/webpack-dev-server/bundle) to see the live reloading version of the app. 11 | 12 | To compile the app into a single file for production run 13 | 14 | $ npm run bundle 15 | -------------------------------------------------------------------------------- /examples/hello-world-react/app/components.js: -------------------------------------------------------------------------------- 1 | let React = require('react') 2 | 3 | // create some components, these could be split into 4 | // multiple files, but keeping them here to simplify the example 5 | module.exports.Application = React.createClass({ 6 | propTypes: { 7 | link: React.PropTypes.func, 8 | children: React.PropTypes.any 9 | }, 10 | render: function () { 11 | return ( 12 |
    13 |
    14 |

    Application

    15 | 20 |
    21 |
    22 | {this.props.children} 23 |
    24 |
    25 | ) 26 | } 27 | }) 28 | 29 | module.exports.Home = React.createClass({ 30 | propTypes: { 31 | link: React.PropTypes.func 32 | }, 33 | render: function () { 34 | return ( 35 |
    36 |

    Tweets

    37 |
    38 | 41 |
    12m12 minutes ago
    42 |
    Another use case for \`this.context\` I think might be valid: forms. They are too painful right now.
    43 |
    44 |
    45 | 48 |
    12m12 minutes ago
    49 |
    I just published “What will Datasmoothie bring to the analytics startup landscape?” https://medium.com/@afanasjevas/what-will-datasmoothie-bring-to-the-analytics-startup-landscape-f7dab70d75c3?source=tw-81c4e81fe6f8-1427630532296
    50 |
    51 |
    52 |
    53 | LNUG ‏@LNUGorg 54 |
    55 |
    52m52 minutes ago
    56 |
    new talks uploaded on our YouTube page - check them out http://bit.ly/1yoXSAO
    57 |
    58 |
    59 | ) 60 | } 61 | }) 62 | 63 | module.exports.Messages = React.createClass({ 64 | render: function () { 65 | return ( 66 |
    67 |

    Messages

    68 |

    You have no direct messages

    69 |
    70 | ) 71 | } 72 | }) 73 | 74 | module.exports.Profile = React.createClass({ 75 | propTypes: { 76 | children: React.PropTypes.any 77 | }, 78 | render: function () { 79 | return ( 80 |
    81 |
    {this.props.children}
    82 |
    83 | ) 84 | } 85 | }) 86 | 87 | module.exports.ProfileIndex = React.createClass({ 88 | propTypes: { 89 | params: React.PropTypes.object 90 | }, 91 | render: function () { 92 | return ( 93 |
    94 |

    {this.props.params.user} profile

    95 |
    96 | ) 97 | } 98 | }) 99 | -------------------------------------------------------------------------------- /examples/hello-world-react/app/index.js: -------------------------------------------------------------------------------- 1 | let cherrytree = require('cherrytree') 2 | let React = require('react') 3 | let components = require('./components') 4 | 5 | let Application = components.Application 6 | let Home = components.Home 7 | let Messages = components.Messages 8 | let Profile = components.Profile 9 | let ProfileIndex = components.ProfileIndex 10 | 11 | let router = cherrytree({log: true}) 12 | 13 | // This is how we define the route map or app structure. 14 | // The nesting here means that all routes in that branch 15 | // of the route tree will get "resolved" and can load data or 16 | // render things on the screen. For example going to 'profile.edit' 17 | // route would load ['application', 'profile', 'profile.edit'] routes 18 | router.map(function (route) { 19 | // We can pass arbitrary options in the second argument of the route 20 | // function call. Because in this case we're using React, let's attach 21 | // the relevant components to each route. 22 | // Path is the only special option that is used to construct and 23 | // match URLs as well as extract URL parameters. 24 | route('application', {path: '/', component: Application, abstract: true}, function () { 25 | route('home', {path: '', component: Home}) 26 | route('messages', {component: Messages}) 27 | route('status', {path: ':user/status/:id'}) 28 | route('profile', {path: ':user', component: Profile, abstract: true}, function () { 29 | route('profile.index', {component: ProfileIndex, path: ''}) 30 | route('profile.lists') 31 | route('profile.edit') 32 | }) 33 | }) 34 | }) 35 | 36 | // Middleware are used to action the transitions. 37 | // For example, here, we grab the React component that 38 | // is backing each route and render them in a nested manner. 39 | router.use(function render (transition) { 40 | let { routes, params, query } = transition 41 | let el = routes.reduceRight(function (element, route) { 42 | let Component = route.options.component 43 | if (Component) { 44 | return React.createElement(Component, { 45 | link: function () { 46 | return router.generate.apply(router, arguments) 47 | }, 48 | params: params, 49 | query: query, 50 | children: element 51 | }) 52 | } else { 53 | return element 54 | } 55 | }, null) 56 | React.render(el, document.querySelector('#app')) 57 | }) 58 | 59 | // Finally, now that everything is set up 60 | // start listening to URL changes and transition the 61 | // app to the route that matches the current browser URL 62 | router.listen().then(function () { 63 | console.log('App started.') 64 | console.log('Try transitioning programmatically with `router.transitionTo("messages")`.') 65 | window.router = router 66 | }) 67 | -------------------------------------------------------------------------------- /examples/hello-world-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Cherrytree Hello World React 6 | 7 | 8 | 9 |
    10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/hello-world-react/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./app') 2 | -------------------------------------------------------------------------------- /examples/hello-world-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello-world", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --colors --no-info --port=8000 --content-base .", 8 | "bundle": "webpack --progress --colors -p", 9 | "test": "mocha" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "babel-core": "^5.8.20", 15 | "babel-loader": "^5.3.2", 16 | "webpack": "^1.5.3", 17 | "webpack-dev-server": "^1.7.0" 18 | }, 19 | "dependencies": { 20 | "cherrytree": "file:../..", 21 | "react": "^0.13.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/hello-world-react/style.css: -------------------------------------------------------------------------------- 1 | @import url(http://fonts.googleapis.com/css?family=Open+Sans:400,700); 2 | 3 | body, html { 4 | margin: 0; 5 | padding: 0; 6 | font-family: 'Open Sans', sans-serif; 7 | } 8 | 9 | .App { 10 | width: 800px; 11 | margin: 0 auto 20px auto; 12 | } 13 | 14 | .App-header { 15 | border-bottom: 1px solid #eee; 16 | } 17 | 18 | .App h1 { 19 | display: inline-block; 20 | } 21 | 22 | .Nav { 23 | display: inline-block; 24 | } 25 | 26 | .Nav-item { 27 | list-style: none; 28 | display: inline-block; 29 | } 30 | 31 | .Nav-item a { 32 | padding: 10px; 33 | } 34 | 35 | .Tweet { 36 | border: 1px solid #eee; 37 | border-radius: 3px; 38 | padding: 10px; 39 | border-bottom: none; 40 | } 41 | 42 | .Tweet:last-child { 43 | border-bottom: 1px solid #eee; 44 | } 45 | 46 | .Tweet-author { 47 | font-weight: bold; 48 | display: inline-block; 49 | } 50 | 51 | .Tweet-time { 52 | color: #888; 53 | display: inline-block; 54 | margin-left: 20px; 55 | font-size: 12px; 56 | } -------------------------------------------------------------------------------- /examples/hello-world-react/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | context: __dirname, 3 | entry: './index', 4 | output: { 5 | path: 'dist', 6 | filename: 'bundle.js' 7 | }, 8 | devtool: 'source-map', 9 | module: { 10 | loaders: [ 11 | { test: /.*\.js$/, exclude: /node_modules/, loader: 'babel' }, 12 | { test: /.*node_modules\/cherrytree\/.*\.js$/, loader: 'babel' } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/server-side-react/README.md: -------------------------------------------------------------------------------- 1 | # Example: server side react 2 | 3 | This is a simple server side application that combines cherrytree + cherrytree-for-react + react + express. 4 | 5 | It's a very simple static twitter like app, taken from the hello-world example, but converted to a React app. 6 | 7 | $ npm install 8 | $ npm start 9 | 10 | Now open [http://localhost:8000](http://localhost:8000). 11 | 12 | The way it works is: 13 | 14 | * [app/server.js](app/server.js) starts an express app and listens to all urls via `app.get('*', render(routes))` 15 | * [app/render.js](app/render.js) is a generic function that instantiates a cherrytree for that request and returns React.renderToString results. It also handles `router.transitionTo()` redirects. 16 | * [app/routes.js](app/routes.js) is a cherrytree route map that describes the route tree and connects routes to React components. This file can be fully reused on the clientside. 17 | -------------------------------------------------------------------------------- /examples/server-side-react/app/render.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a generic render helper for 3 | * cherrytree + react + express 4 | * 5 | * There are more concerns in a real life app 6 | * than are addressed in this simple example, 7 | * but this should be a good starting point. 8 | * 9 | * In particular, with further tweaks, this could 10 | * be turned into an isomorphic app, since we could 11 | * render the same routes on the client. 12 | * 13 | * Data fetching is not addressed here, but see 14 | * the https://github.com/KidkArolis/cherrytree-redux-react-example 15 | * for how data management could be addressed in 16 | * a cherrytree routed app. 17 | * 18 | * Data fetching could be configured via cherrytree 19 | * middleware since the middleware would get the transition 20 | * with all required routes passed in and returning a 21 | * promise there will block the rendering. 22 | */ 23 | 24 | let cherrytree = require('cherrytree') 25 | let { Router } = require('cherrytree-for-react') 26 | let React = require('react') 27 | 28 | module.exports = function (routes, options) { 29 | return function render (req, res, next) { 30 | let url = req.url 31 | let router = cherrytree(Object.assign({location: 'memory'}, options)) 32 | .map(routes()) 33 | // just an example of how redirects work, 34 | // you can setup various redirect strategies 35 | // in general e.g. 36 | // * define a `redirect` option in your route map 37 | // and handle it in a middleware 38 | // * redirect in a static method in your components 39 | // and call that method from within a middleware 40 | // * in this case, we just redirect straight from the 41 | // middleware 42 | .use(function adminRedirectDemo (transition) { 43 | if (transition.path === '/admin') { 44 | router.transitionTo('home') 45 | return 46 | } 47 | }) 48 | 49 | // kick off routing 50 | let transition = router.listen(url) 51 | 52 | // after transitioning completes - render or redirect 53 | transition 54 | .then(function () { 55 | // the component from cherrytree-for-react 56 | // behaves differently when you pass in an already 57 | // started cherrytree - on the client, the usage 58 | // is slightly different 59 | res.send(React.renderToString()) 60 | }).catch(function (err) { 61 | if (err.type === 'TransitionRedirected' && err.nextPath) { 62 | res.redirect(err.nextPath) 63 | } else { 64 | next(err) 65 | } 66 | }) 67 | 68 | // after everything - clean up 69 | // this also makes sure transitions are cancelled 70 | // during redirects. I.e. a redirect will reject 71 | // this transition #1 and destroying the router 72 | // will make sure that all subsequence transitions 73 | // won't happen anymore 74 | transition.then(cleanup).catch(cleanup) 75 | function cleanup () { 76 | router.destroy() 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /examples/server-side-react/app/routes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is where the route tree is defined 3 | * together with corresponding React components. 4 | * Notice how there's nothing cherrytree pecific here, 5 | * it's just components and a generic function. 6 | */ 7 | 8 | let React = require('react') 9 | 10 | module.exports = function routes () { 11 | return (route) => { 12 | route('application', {path: '/', component: application, abstract: true}, () => { 13 | route('home', {path: '', component: home}) 14 | route('messages', {component: messages}) 15 | route('status', {path: ':user/status/:id'}) 16 | route('profile', {path: ':user', component: profile, abstract: true}, () => { 17 | route('profile.index', {path: '', component: profileIndex}) 18 | route('profile.lists') 19 | route('profile.edit') 20 | }) 21 | }) 22 | } 23 | } 24 | 25 | // create some components, these could be split into 26 | // multiple files, but keeping them here to simplify the example 27 | let application = React.createClass({ 28 | propTypes: { 29 | children: React.PropTypes.array 30 | }, 31 | contextTypes: { 32 | router: React.PropTypes.object.isRequired 33 | }, 34 | render: function () { 35 | let router = this.context.router 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 |
    43 |
    44 |

    Application

    45 | 50 |
    51 |
    52 | {this.props.children} 53 |
    54 |
    55 | 56 | 57 | ) 58 | } 59 | }) 60 | 61 | let home = React.createClass({ 62 | contextTypes: { 63 | router: React.PropTypes.object.isRequired 64 | }, 65 | render: function () { 66 | let router = this.context.router 67 | return ( 68 |
    69 |

    Tweets

    70 |
    71 | 74 |
    12m12 minutes ago
    75 |
    Another use case for \`this.context\` I think might be valid: forms. They are too painful right now.
    76 |
    77 |
    78 | 81 |
    12m12 minutes ago
    82 |
    I just published “What will Datasmoothie bring to the analytics startup landscape?” https://medium.com/@afanasjevas/what-will-datasmoothie-bring-to-the-analytics-startup-landscape-f7dab70d75c3?source=tw-81c4e81fe6f8-1427630532296
    83 |
    84 |
    85 |
    86 | LNUG ‏@LNUGorg 87 |
    88 |
    52m52 minutes ago
    89 |
    new talks uploaded on our YouTube page - check them out http://bit.ly/1yoXSAO
    90 |
    91 |
    92 | ) 93 | } 94 | }) 95 | 96 | let messages = React.createClass({ 97 | render: function () { 98 | return ( 99 |
    100 |

    Messages

    101 |

    You have no direct messages

    102 |
    103 | ) 104 | } 105 | }) 106 | 107 | let profile = React.createClass({ 108 | propTypes: { 109 | children: React.PropTypes.array 110 | }, 111 | render: function () { 112 | return ( 113 |
    114 |
    {this.props.children}
    115 |
    116 | ) 117 | } 118 | }) 119 | 120 | let profileIndex = React.createClass({ 121 | propTypes: { 122 | params: React.PropTypes.object 123 | }, 124 | render: function () { 125 | return ( 126 |
    127 |

    {this.props.params.user} profile

    128 |
    129 | ) 130 | } 131 | }) 132 | -------------------------------------------------------------------------------- /examples/server-side-react/app/server.js: -------------------------------------------------------------------------------- 1 | let express = require('express') 2 | let morgan = require('morgan') 3 | let app = express() 4 | let render = require('./render') 5 | let routes = require('./routes') 6 | 7 | app.use(morgan('dev')) 8 | app.use(express.static('assets')) 9 | 10 | /** 11 | * handle all urls and pass the req, res to the 12 | * render helper function that creates a cherrytree 13 | * instance and renders out the app / or redirects 14 | */ 15 | app.get('*', render(routes, { log: true })) 16 | 17 | app.listen(8000, function () { 18 | console.log('Cherrytree server side app started on http://localhost:8000') 19 | }) 20 | -------------------------------------------------------------------------------- /examples/server-side-react/assets/style.css: -------------------------------------------------------------------------------- 1 | @import url(http://fonts.googleapis.com/css?family=Open+Sans:400,700); 2 | 3 | body, html { 4 | margin: 0; 5 | padding: 0; 6 | font-family: 'Open Sans', sans-serif; 7 | } 8 | 9 | .App { 10 | width: 800px; 11 | margin: 0 auto 20px auto; 12 | } 13 | 14 | .App-header { 15 | border-bottom: 1px solid #eee; 16 | } 17 | 18 | .App h1 { 19 | display: inline-block; 20 | } 21 | 22 | .Nav { 23 | display: inline-block; 24 | } 25 | 26 | .Nav-item { 27 | list-style: none; 28 | display: inline-block; 29 | } 30 | 31 | .Nav-item a { 32 | padding: 10px; 33 | } 34 | 35 | .Tweet { 36 | border: 1px solid #eee; 37 | border-radius: 3px; 38 | padding: 10px; 39 | border-bottom: none; 40 | } 41 | 42 | .Tweet:last-child { 43 | border-bottom: 1px solid #eee; 44 | } 45 | 46 | .Tweet-author { 47 | font-weight: bold; 48 | display: inline-block; 49 | } 50 | 51 | .Tweet-time { 52 | color: #888; 53 | display: inline-block; 54 | margin-left: 20px; 55 | font-size: 12px; 56 | } -------------------------------------------------------------------------------- /examples/server-side-react/index.js: -------------------------------------------------------------------------------- 1 | require('babel/register')({ 2 | only: [ 3 | /server-side-react\/app/, 4 | /cherrytree/ 5 | ] 6 | }) 7 | require('./app/server') 8 | -------------------------------------------------------------------------------- /examples/server-side-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server-side-react", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index", 8 | "test": "standard" 9 | }, 10 | "author": "Karolis Narkevicius ", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "babel": "^5.8.21", 14 | "babel-core": "^5.8.20" 15 | }, 16 | "dependencies": { 17 | "cherrytree": "file:../..", 18 | "cherrytree-for-react": "kidkarolis/cherrytree-for-react", 19 | "express": "^4.13.3", 20 | "morgan": "^1.6.1", 21 | "react": "^0.13.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/vanilla-blog/README.md: -------------------------------------------------------------------------------- 1 | # Example: vanilla blog 2 | 3 | This is a simple blog like website using no frameworks other than Cherrytree for routing. 4 | It simply renders out some html templates in each route. 5 | 6 | ``` 7 | npm install 8 | npm start 9 | ``` 10 | 11 | Now open [http://localhost:8000](http://localhost:8000). -------------------------------------------------------------------------------- /examples/vanilla-blog/client/app.js: -------------------------------------------------------------------------------- 1 | var Promise = require('when').Promise 2 | var cherrytree = require('cherrytree') 3 | var getHandler = require('./handler') 4 | 5 | require('./styles/app.css') 6 | 7 | // create the router 8 | var router = window.router = cherrytree({ 9 | log: true 10 | }) 11 | 12 | // define the route map 13 | router.map(function (route) { 14 | route('application', {path: '/', abstract: true}, function () { 15 | route('home', {path: ''}) 16 | route('about') 17 | route('faq') 18 | route('posts', {abstract: true}, function () { 19 | route('posts.index', {path: ''}) 20 | route('posts.popular') 21 | route('posts.search', { path: 'search/:query' }) 22 | route('posts.show', { path: ':id' }) 23 | }) 24 | }) 25 | }) 26 | 27 | // implement a set of middleware 28 | 29 | // load and attach route handlers 30 | // this can load handlers dynamically (TODO) 31 | router.use(function loadHandlers (transition) { 32 | transition.routes.forEach(function (route, i) { 33 | var handler = getHandler(route.name) 34 | handler.name = route.name 35 | handler.router = router 36 | var parentRoute = transition.routes[i - 1] 37 | if (parentRoute) { 38 | handler.parent = parentRoute.handler 39 | } 40 | route.handler = handler 41 | }) 42 | }) 43 | 44 | // willTransition hook 45 | router.use(function willTransition (transition) { 46 | transition.prev.routes.forEach(function (route) { 47 | route.handler.willTransition && route.handler.willTransition(transition) 48 | }) 49 | }) 50 | 51 | // deactive up old routes 52 | // they also get a chance to abort the transition (TODO) 53 | router.use(function deactivateHook (transition) { 54 | transition.prev.routes.forEach(function (route) { 55 | route.handler.deactivate() 56 | }) 57 | }) 58 | 59 | // model hook 60 | // with the loading hook (TODO) 61 | router.use(function modelHook (transition) { 62 | var prevContext = Promise.resolve() 63 | return Promise.all(transition.routes.map(function (route) { 64 | prevContext = Promise.resolve(route.handler.model(transition.params, prevContext, transition)) 65 | return prevContext 66 | })) 67 | }) 68 | 69 | // activate hook 70 | // which only reactives routes starting at the match point (TODO) 71 | router.use(function activateHook (transition, contexts) { 72 | transition.routes.forEach(function (route, i) { 73 | route.handler.activate(contexts[i]) 74 | }) 75 | }) 76 | 77 | // start the routing 78 | router.listen() 79 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/handler.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | var BaseHandler = require('base_handler') 3 | 4 | var handlers = { 5 | 'application': require('./screens/app'), 6 | 'home': require('./screens/app/screens/home'), 7 | 'about': require('./screens/app/screens/about'), 8 | 'faq': require('./screens/app/screens/faq'), 9 | 'posts': require('./screens/app/screens/posts'), 10 | 'posts.index': require('./screens/app/screens/posts/screens/index'), 11 | 'posts.show': require('./screens/app/screens/posts/screens/show'), 12 | 'posts.search': require('./screens/app/screens/posts/screens/search') 13 | } 14 | 15 | module.exports = function getHandler (routeName) { 16 | return handlers[routeName] || _.clone(BaseHandler) 17 | } 18 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/app.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery') 2 | var _ = require('lodash') 3 | var template = require('./templates/app.html') 4 | var BaseHandler = require('base_handler') 5 | 6 | module.exports = _.extend({}, BaseHandler, { 7 | template: template, 8 | model: function () { 9 | var context = { 10 | appRnd: Math.random() 11 | } 12 | // activate eagerly - we want to render this route 13 | // right while the other routes might be loading 14 | this.activate(context) 15 | return context 16 | }, 17 | templateData: function (context) { 18 | return { 19 | rnd: context.appRnd 20 | } 21 | }, 22 | outlet: function () { 23 | return $(document.body) 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./app') 2 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/about/about.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | var template = require('./templates/about.html') 3 | var BaseHandler = require('base_handler') 4 | 5 | module.exports = _.extend({}, BaseHandler, { 6 | template: template 7 | }) 8 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/about/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./about') 2 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/about/templates/about.html: -------------------------------------------------------------------------------- 1 |
    2 |

    About

    3 |
    4 |
    5 |

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rhoncus condimentum sem a facilisis. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas lacinia fringilla tristique. Ut sollicitudin felis sem, in aliquam lectus eleifend nec. Pellentesque id nisi non arcu venenatis convallis vel id nulla. Nullam ut accumsan felis. Nunc nec tincidunt sapien, et posuere massa.

    6 |

    Cras sollicitudin neque ac erat fermentum, quis pulvinar dolor porta. Donec enim lacus, scelerisque ac iaculis eu, rhoncus sed urna. In hac habitasse platea dictumst. Nullam sodales, leo at congue ultricies, mauris sapien ornare ipsum, id dictum orci nulla at leo. Nullam non tristique mauris. Donec aliquam lobortis tortor in volutpat. In at sem sed felis faucibus vulputate. Duis risus leo, aliquet ut lectus vel, sollicitudin egestas dui. Morbi eget justo tristique, tincidunt turpis interdum, faucibus turpis. Sed venenatis ut augue non accumsan. Aliquam at lorem et dui accumsan sodales. Mauris fermentum, ligula imperdiet pharetra feugiat, erat tortor sollicitudin nulla, et condimentum mauris lectus ullamcorper enim. Integer volutpat mauris nisl, non congue turpis fringilla et. Sed rhoncus mollis libero, ut lacinia quam elementum in.

    7 |

    Pellentesque eu arcu condimentum, vulputate leo nec, tincidunt massa. Vestibulum leo libero, aliquet nec enim quis, consequat faucibus elit. Sed tristique et velit vel iaculis. Sed suscipit commodo tellus nec imperdiet. Aenean tristique at urna eget aliquet. Etiam vestibulum ligula ac nunc viverra, quis scelerisque est facilisis. Praesent fermentum eros urna, nec consequat sapien accumsan sit amet. Maecenas congue elit id lacinia scelerisque. Mauris in nibh justo.

    8 |

    Suspendisse potenti. Vestibulum eu molestie diam. Quisque tristique volutpat felis, eget tempor lorem ullamcorper sed. Vivamus aliquam cursus mollis. Cras accumsan justo nec augue dignissim lacinia. Mauris eu tortor lacus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Quisque at lacus a elit elementum semper. Morbi dictum metus lorem, eu ornare nisi gravida ut. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse velit odio, posuere sed mi quis, elementum elementum urna. Etiam mi mi, sollicitudin at ipsum sit amet, posuere gravida sapien.

    9 |

    Curabitur vel mauris id velit lobortis rutrum. In hac habitasse platea dictumst. Fusce in mattis ante, eget tristique ipsum. Sed lorem augue, laoreet eu viverra eget, porttitor sed massa. Quisque suscipit mauris sem, vitae varius arcu gravida sit amet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Ut ipsum libero, fringilla eu nibh vitae, porta euismod lacus. Curabitur arcu nibh, tincidunt in fringilla quis, rutrum id purus.

    10 |
    -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/faq/faq.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | var template = require('./templates/faq.html') 3 | var BaseHandler = require('base_handler') 4 | 5 | module.exports = _.extend({}, BaseHandler, { 6 | template: template 7 | }) 8 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/faq/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./faq') 2 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/faq/templates/faq.html: -------------------------------------------------------------------------------- 1 |
    2 |

    FAQ

    3 |
    4 |

    FAQ

    5 |

    Sorted by:>

    -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/home/home.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | var template = require('./templates/home.html') 3 | var BaseHandler = require('base_handler') 4 | 5 | module.exports = _.extend({}, BaseHandler, { 6 | template: template 7 | }) 8 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/home/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./home') 2 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/home/templates/home.html: -------------------------------------------------------------------------------- 1 |
    2 |

    Welcome

    3 |
    4 |

    This is a little application demonstrating some of the Cherrytree's features

    5 |

    This application is also used in the functional tests!

    -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./posts') 2 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/posts.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | var BaseHandler = require('base_handler') 3 | var template = require('./templates/posts.html') 4 | 5 | module.exports = _.extend({}, BaseHandler, { 6 | template: template, 7 | model: function (params, context) { 8 | return context.then(function (context) { 9 | return new Promise(function (resolve) { 10 | resolve(_.extend(context, { 11 | allPostsData: ['foo', 'bar'] 12 | })) 13 | }) 14 | }) 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/screens/index/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | model: function (params, context, transition) { 3 | this.router.replaceWith('posts.show', {id: 1}) 4 | }, 5 | activate: function () {}, 6 | deactivate: function () {} 7 | } 8 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/screens/search/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./search') 2 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/screens/search/search.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | var BaseHandler = require('base_handler') 3 | 4 | module.exports = _.extend({}, BaseHandler, { 5 | model: function (params) { 6 | return params 7 | }, 8 | activate: function (context) { 9 | this.render(context) 10 | }, 11 | update: function (context) { 12 | this.render(context) 13 | }, 14 | // queryParamsDidChange: function (queryParams) { 15 | // var context = this.getContext() 16 | // context.queryParams = queryParams 17 | // this.setContext(context) 18 | // this.render(context) 19 | // }, 20 | render: function (context) { 21 | if (context.query === 'mine') { 22 | this.outlet().html('My posts...') 23 | } else { 24 | this.outlet().html('No matching blog posts were found') 25 | } 26 | if (context.queryParams.sortBy) { 27 | this.outlet().append('
    Sorting by:' + context.queryParams.sortBy + '
    ') 28 | } 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/screens/show/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./show') 2 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/screens/show/show.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | var template = require('./templates/show.html') 3 | var BaseHandler = require('base_handler') 4 | 5 | module.exports = _.extend({}, BaseHandler, { 6 | template: template, 7 | willTransition: function (transition) { 8 | // if (this.postId === '2') { 9 | // transition.cancel() 10 | // } 11 | }, 12 | model: function (params, context) { 13 | if (!this.sessionStore) { 14 | this.sessionStore = 1 15 | } else { 16 | this.sessionStore++ 17 | } 18 | var self = this 19 | return context.then(function (context) { 20 | self.postId = params.id 21 | return new Promise(function (resolve) { 22 | resolve({title: 'Blog ' + params.id, subtitle: context.allPostsData[0] + context.appRnd}) 23 | }) 24 | }) 25 | }, 26 | templateData: function (context) { 27 | return { 28 | title: 'Blog post #' + context.title + ' (' + context.subtitle + ')' 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/screens/show/templates/show.html: -------------------------------------------------------------------------------- 1 |
    2 |

    <%- title %>

    3 |
    4 |

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rhoncus condimentum sem a facilisis. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas lacinia fringilla tristique. Ut sollicitudin felis sem, in aliquam lectus eleifend nec. Pellentesque id nisi non arcu venenatis convallis vel id nulla. Nullam ut accumsan felis. Nunc nec tincidunt sapien, et posuere massa.

    5 |

    Cras sollicitudin neque ac erat fermentum, quis pulvinar dolor porta. Donec enim lacus, scelerisque ac iaculis eu, rhoncus sed urna. In hac habitasse platea dictumst. Nullam sodales, leo at congue ultricies, mauris sapien ornare ipsum, id dictum orci nulla at leo. Nullam non tristique mauris. Donec aliquam lobortis tortor in volutpat. In at sem sed felis faucibus vulputate. Duis risus leo, aliquet ut lectus vel, sollicitudin egestas dui. Morbi eget justo tristique, tincidunt turpis interdum, faucibus turpis. Sed venenatis ut augue non accumsan. Aliquam at lorem et dui accumsan sodales. Mauris fermentum, ligula imperdiet pharetra feugiat, erat tortor sollicitudin nulla, et condimentum mauris lectus ullamcorper enim. Integer volutpat mauris nisl, non congue turpis fringilla et. Sed rhoncus mollis libero, ut lacinia quam elementum in.

    6 |

    Pellentesque eu arcu condimentum, vulputate leo nec, tincidunt massa. Vestibulum leo libero, aliquet nec enim quis, consequat faucibus elit. Sed tristique et velit vel iaculis. Sed suscipit commodo tellus nec imperdiet. Aenean tristique at urna eget aliquet. Etiam vestibulum ligula ac nunc viverra, quis scelerisque est facilisis. Praesent fermentum eros urna, nec consequat sapien accumsan sit amet. Maecenas congue elit id lacinia scelerisque. Mauris in nibh justo.

    7 |

    Suspendisse potenti. Vestibulum eu molestie diam. Quisque tristique volutpat felis, eget tempor lorem ullamcorper sed. Vivamus aliquam cursus mollis. Cras accumsan justo nec augue dignissim lacinia. Mauris eu tortor lacus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Quisque at lacus a elit elementum semper. Morbi dictum metus lorem, eu ornare nisi gravida ut. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse velit odio, posuere sed mi quis, elementum elementum urna. Etiam mi mi, sollicitudin at ipsum sit amet, posuere gravida sapien.

    8 |

    Curabitur vel mauris id velit lobortis rutrum. In hac habitasse platea dictumst. Fusce in mattis ante, eget tristique ipsum. Sed lorem augue, laoreet eu viverra eget, porttitor sed massa. Quisque suscipit mauris sem, vitae varius arcu gravida sit amet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Ut ipsum libero, fringilla eu nibh vitae, porta euismod lacus. Curabitur arcu nibh, tincidunt in fringilla quis, rutrum id purus.

    -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/screens/posts/templates/posts.html: -------------------------------------------------------------------------------- 1 |
    2 | 9 |
    10 |
    -------------------------------------------------------------------------------- /examples/vanilla-blog/client/screens/app/templates/app.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 |
    6 |

    Cherrytree

    7 |

    A hierarchical stateful router. <%- rnd %>

    8 |
    9 | 15 |
    16 |
    17 |
    18 |
    19 |
    20 |
    -------------------------------------------------------------------------------- /examples/vanilla-blog/client/shared/base_handler.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery') 2 | var _ = require('lodash') 3 | var Promise = require('when').Promise 4 | 5 | module.exports = { 6 | template: _.template('
    '), 7 | model: function (params) { 8 | var self = this 9 | return new Promise(function (resolve) { 10 | self.timeout = setTimeout(function () { 11 | resolve(params) 12 | }, 300) 13 | }) 14 | }, 15 | deactivate: function () { 16 | window.clearTimeout(this.timeout) 17 | if (this.$view) { 18 | this.$view.remove() 19 | } 20 | }, 21 | templateData: function () { 22 | return {} 23 | }, 24 | view: function (context) { 25 | var tpl = '
    ' + this.template(this.templateData(context)) + '
    ' 26 | var router = this.router 27 | tpl = tpl.replace(/\{\{link\:(.*)\}\}/g, function (match, routeId) { 28 | return router.generate(routeId) 29 | }) 30 | return $(tpl) 31 | }, 32 | activate: function () { 33 | this.$view = this.view.apply(this, arguments) 34 | this.$outlet = this.$view.find('.outlet') 35 | this.outlet().html(this.$view) 36 | }, 37 | outlet: function () { 38 | var parent = this.parent 39 | while (parent) { 40 | if (parent.$outlet) { 41 | return parent.$outlet 42 | } else { 43 | parent = parent.parent 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/vanilla-blog/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cherrytree Demo Application 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/vanilla-blog/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./client/app') 2 | -------------------------------------------------------------------------------- /examples/vanilla-blog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanilla-blog", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --colors --no-info --port=8000 --content-base .", 8 | "test": "mocha" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "babel-core": "^4.4.3", 14 | "babel-loader": "^4.0.0", 15 | "style-loader": "^0.8.3", 16 | "webpack": "^1.5.3", 17 | "webpack-dev-server": "^1.7.0" 18 | }, 19 | "dependencies": { 20 | "cherrytree": "file:../..", 21 | "css-loader": "^0.9.1", 22 | "jquery": "^2.1.4", 23 | "lodash": "^3.10.1", 24 | "underscore": "^1.8.1", 25 | "underscore-template-loader": "^0.2.5", 26 | "when": "^3.7.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/vanilla-blog/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | context: __dirname, 3 | entry: './index', 4 | output: { 5 | path: 'dist', 6 | filename: 'bundle.js' 7 | }, 8 | devtool: 'source-map', 9 | resolve: { 10 | modulesDirectories: ['node_modules', 'shared'] 11 | }, 12 | module: { 13 | loaders: [ 14 | { test: /.*\.js$/, exclude: /node_modules/, loader: 'babel' }, 15 | { test: /.*node_modules\/cherrytree\/.*\.js$/, loader: 'babel' }, 16 | { test: /\.css$/, loader: 'style!css' }, 17 | { test: /\.html$/, loader: 'underscore-template' } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/router') 2 | -------------------------------------------------------------------------------- /karma.conf-ci.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var config = require('./karma.conf').config 3 | var yargs = require('yargs') 4 | 5 | var browsers = (yargs.argv.b || '').split(',') 6 | 7 | // Use ENV vars on CI and sauce.json locally to get credentials 8 | if (!process.env.SAUCE_USERNAME) { 9 | if (!fs.existsSync('sauce.json')) { 10 | console.log('Create a sauce.json with your credentials {username,accessKey}.') 11 | process.exit(1) 12 | } else { 13 | var sauce = require('./sauce') 14 | process.env.SAUCE_USERNAME = sauce.username 15 | process.env.SAUCE_ACCESS_KEY = sauce.accessKey 16 | } 17 | } 18 | 19 | var platforms = [ 20 | ['android', '5.1', 'Linux'], 21 | ['chrome', '32', 'Windows 8.1'], 22 | ['chrome', '43', 'Linux'], 23 | ['chrome', 'beta', 'OS X 10.11'], 24 | ['firefox', '26', 'Windows 8.1'], 25 | ['firefox', '40', 'Windows 8.1'], 26 | ['safari', '6', 'OS X 10.8'], 27 | ['safari', '7', 'OS X 10.9'], 28 | ['internet explorer', '9', 'Windows 7'], 29 | ['internet explorer', '10', 'Windows 8'], 30 | ['internet explorer', '11', 'Windows 8.1'] 31 | ] 32 | 33 | var customLaunchers = platforms.reduce(function (memo, platform, i) { 34 | if (!browsers || browsers.indexOf(platform[0]) > -1) { 35 | memo['SL_' + i + '_' + platform[0] + platform[1]] = { 36 | base: 'SauceLabs', 37 | platform: platform[2], 38 | browserName: platform[0], 39 | version: platform[1] 40 | } 41 | } 42 | return memo 43 | }, {}) 44 | 45 | module.exports = function (c) { 46 | c.set(Object.assign(config(c), { 47 | sauceLabs: { 48 | testName: 'Cherrytree', 49 | build: process.env.CI_BUILD_NUMBER, 50 | recordVideo: false, 51 | recordScreenshots: false 52 | }, 53 | customLaunchers: customLaunchers, 54 | browsers: Object.keys(customLaunchers), 55 | reporters: ['dots', 'saucelabs'], 56 | singleRun: true, 57 | browserDisconnectTimeout: 10000, // default 2000 58 | browserDisconnectTolerance: 1, // default 0 59 | browserNoActivityTimeout: 3 * 60 * 1000, // default 10000 60 | captureTimeout: 3 * 60 * 1000 // default 60000 61 | })) 62 | } 63 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run karma start --no-coverage to get non instrumented code to show up in the dev tools 3 | */ 4 | 5 | var webpackConfig = require('./webpack.config') 6 | 7 | function config (c) { 8 | return { 9 | 10 | frameworks: ['mocha', 'effroi'], 11 | 12 | preprocessors: { 13 | 'tests/index.js': ['webpack', 'sourcemap'] 14 | }, 15 | 16 | files: [ 17 | 'tests/index.js' 18 | ], 19 | 20 | reporters: c.coverage ? ['progress', 'coverage'] : ['progress'], 21 | 22 | // this watcher watches when bundled files are updated 23 | autoWatch: true, 24 | 25 | webpack: Object.assign(webpackConfig, { 26 | entry: undefined, 27 | // this watcher watches when source files are updated 28 | watch: true, 29 | devtool: 'inline-source-map', 30 | module: Object.assign(webpackConfig.module, { 31 | loaders: [{ 32 | test: /\.js$/, 33 | exclude: /node_modules/, 34 | loader: 'babel', 35 | query: { 36 | presets: ['es2015'], 37 | plugins: ['transform-runtime', 'transform-async-to-generator'] 38 | } 39 | }], 40 | postLoaders: c.coverage ? [{ 41 | test: /\.js/, 42 | exclude: /(test|node_modules)/, 43 | loader: 'istanbul-instrumenter' 44 | }] : [] 45 | }) 46 | }), 47 | 48 | webpackServer: { 49 | noInfo: true 50 | }, 51 | 52 | client: { 53 | useIframe: true, 54 | captureConsole: true, 55 | mocha: { 56 | ui: 'qunit' 57 | } 58 | }, 59 | 60 | browsers: [process.env.TRAVIS ? 'Firefox' : 'Chrome'], 61 | browserNoActivityTimeout: 30000, 62 | 63 | coverageReporter: c.coverage ? { 64 | reporters: [ 65 | {type: 'html', dir: 'coverage/'}, 66 | {type: 'text-summary'} 67 | ] 68 | } : {} 69 | } 70 | } 71 | 72 | module.exports = function (c) { 73 | c.coverage = c.coverage !== false 74 | c.set(config(c)) 75 | } 76 | 77 | module.exports.config = config 78 | -------------------------------------------------------------------------------- /lib/dash.js: -------------------------------------------------------------------------------- 1 | let toString = Object.prototype.toString 2 | let keys = Object.keys 3 | let assoc = (obj, attr, val) => { obj[attr] = val; return obj } 4 | let isArray = obj => toString.call(obj) === '[object Array]' 5 | 6 | export let clone = obj => 7 | obj 8 | ? isArray(obj) 9 | ? obj.slice(0) 10 | : extend({}, obj) 11 | : obj 12 | 13 | export let pick = (obj, attrs) => 14 | attrs.reduce((acc, attr) => 15 | obj[attr] === undefined 16 | ? acc 17 | : assoc(acc, attr, obj[attr]), {}) 18 | 19 | export let isEqual = (obj1, obj2) => 20 | keys(obj1).length === keys(obj2).length && 21 | keys(obj1).reduce((acc, key) => acc && obj2[key] === obj1[key], true) 22 | 23 | export let extend = (obj, ...rest) => { 24 | rest.forEach(source => { 25 | if (source) { 26 | for (let prop in source) { 27 | obj[prop] = source[prop] 28 | } 29 | } 30 | }) 31 | return obj 32 | } 33 | 34 | export let find = (list, pred) => { 35 | for (let x of list) if (pred(x)) return x 36 | } 37 | 38 | export let isString = obj => 39 | Object.prototype.toString.call(obj) === '[object String]' 40 | -------------------------------------------------------------------------------- /lib/dsl.js: -------------------------------------------------------------------------------- 1 | import { clone } from './dash' 2 | import invariant from './invariant' 3 | 4 | export default function dsl (callback) { 5 | let ancestors = [] 6 | let matches = {} 7 | let names = {} 8 | 9 | callback(function route (name, options, callback) { 10 | let routes 11 | 12 | invariant(!names[name], 'Route names must be unique, but route "%s" is declared multiple times', name) 13 | 14 | names[name] = true 15 | 16 | if (arguments.length === 1) { 17 | options = {} 18 | } 19 | 20 | if (arguments.length === 2 && typeof options === 'function') { 21 | callback = options 22 | options = {} 23 | } 24 | 25 | if (typeof options.path !== 'string') { 26 | let parts = name.split('.') 27 | options.path = parts[parts.length - 1] 28 | } 29 | 30 | // go to the next level 31 | if (callback) { 32 | ancestors = ancestors.concat(name) 33 | callback() 34 | routes = pop() 35 | ancestors.splice(-1) 36 | } 37 | 38 | // add the node to the tree 39 | push({ 40 | name: name, 41 | path: options.path, 42 | routes: routes || [], 43 | options: options, 44 | ancestors: clone(ancestors) 45 | }) 46 | }) 47 | 48 | function pop () { 49 | return matches[currentLevel()] || [] 50 | } 51 | 52 | function push (route) { 53 | matches[currentLevel()] = matches[currentLevel()] || [] 54 | matches[currentLevel()].push(route) 55 | } 56 | 57 | function currentLevel () { 58 | return ancestors.join('.') 59 | } 60 | 61 | return pop() 62 | } 63 | -------------------------------------------------------------------------------- /lib/events.js: -------------------------------------------------------------------------------- 1 | let events = createEvents() 2 | 3 | export default events 4 | 5 | function createEvents () { 6 | let exp = {} 7 | 8 | if (typeof window === 'undefined') { 9 | return exp 10 | } 11 | 12 | /** 13 | * DOM Event bind/unbind 14 | */ 15 | 16 | let bind = window.addEventListener ? 'addEventListener' : 'attachEvent' 17 | let unbind = window.removeEventListener ? 'removeEventListener' : 'detachEvent' 18 | let prefix = bind !== 'addEventListener' ? 'on' : '' 19 | 20 | /** 21 | * Bind `el` event `type` to `fn`. 22 | * 23 | * @param {Element} el 24 | * @param {String} type 25 | * @param {Function} fn 26 | * @param {Boolean} capture 27 | * @return {Function} 28 | * @api public 29 | */ 30 | 31 | exp.bind = function (el, type, fn, capture) { 32 | el[bind](prefix + type, fn, capture || false) 33 | return fn 34 | } 35 | 36 | /** 37 | * Unbind `el` event `type`'s callback `fn`. 38 | * 39 | * @param {Element} el 40 | * @param {String} type 41 | * @param {Function} fn 42 | * @param {Boolean} capture 43 | * @return {Function} 44 | * @api public 45 | */ 46 | 47 | exp.unbind = function (el, type, fn, capture) { 48 | el[unbind](prefix + type, fn, capture || false) 49 | return fn 50 | } 51 | 52 | return exp 53 | } 54 | -------------------------------------------------------------------------------- /lib/invariant.js: -------------------------------------------------------------------------------- 1 | export default function invariant (condition, format, a, b, c, d, e, f) { 2 | if (!condition) { 3 | let args = [a, b, c, d, e, f] 4 | let argIndex = 0 5 | let error = new Error( 6 | 'Invariant Violation: ' + 7 | format.replace(/%s/g, () => args[argIndex++]) 8 | ) 9 | error.framesToPop = 1 // we don't care about invariant's own frame 10 | throw error 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/links.js: -------------------------------------------------------------------------------- 1 | import events from './events' 2 | 3 | /** 4 | * Handle link delegation on `el` or the document, 5 | * and invoke `fn(e)` when clickable. 6 | * 7 | * @param {Element|Function} el or fn 8 | * @param {Function} [fn] 9 | * @api public 10 | */ 11 | 12 | export function intercept (el, fn) { 13 | // default to document 14 | if (typeof el === 'function') { 15 | fn = el 16 | el = document 17 | } 18 | 19 | let cb = delegate(el, 'click', function (e, el) { 20 | if (clickable(e, el)) fn(e, el) 21 | }) 22 | 23 | return function dispose () { 24 | undelegate(el, 'click', cb) 25 | } 26 | } 27 | 28 | function link (element) { 29 | element = {parentNode: element} 30 | 31 | let root = document 32 | 33 | // Make sure `element !== document` and `element != null` 34 | // otherwise we get an illegal invocation 35 | while ((element = element.parentNode) && element !== document) { 36 | if (element.tagName.toLowerCase() === 'a') { 37 | return element 38 | } 39 | // After `matches` on the edge case that 40 | // the selector matches the root 41 | // (when the root is not the document) 42 | if (element === root) { 43 | return 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * Delegate event `type` to links 50 | * and invoke `fn(e)`. A callback function 51 | * is returned which may be passed to `.unbind()`. 52 | * 53 | * @param {Element} el 54 | * @param {String} selector 55 | * @param {String} type 56 | * @param {Function} fn 57 | * @param {Boolean} capture 58 | * @return {Function} 59 | * @api public 60 | */ 61 | 62 | function delegate (el, type, fn) { 63 | return events.bind(el, type, function (e) { 64 | let target = e.target || e.srcElement 65 | let el = link(target) 66 | if (el) { 67 | fn(e, el) 68 | } 69 | }) 70 | } 71 | 72 | /** 73 | * Unbind event `type`'s callback `fn`. 74 | * 75 | * @param {Element} el 76 | * @param {String} type 77 | * @param {Function} fn 78 | * @param {Boolean} capture 79 | * @api public 80 | */ 81 | 82 | function undelegate (el, type, fn) { 83 | events.unbind(el, type, fn) 84 | } 85 | 86 | /** 87 | * Check if `e` is clickable. 88 | */ 89 | 90 | function clickable (e, el) { 91 | if (which(e) !== 1) return 92 | if (e.metaKey || e.ctrlKey || e.shiftKey) return 93 | if (e.defaultPrevented) return 94 | 95 | // check target 96 | if (el.target) return 97 | 98 | // check for data-bypass attribute 99 | if (el.getAttribute('data-bypass') !== null) return 100 | 101 | // inspect the href 102 | let href = el.getAttribute('href') 103 | if (!href || href.length === 0) return 104 | // don't handle hash links 105 | if (href[0] === '#') return 106 | // external/absolute links 107 | if (href.indexOf('http://') === 0 || href.indexOf('https://') === 0) return 108 | // email links 109 | if (href.indexOf('mailto:') === 0) return 110 | // don't intercept javascript links 111 | /* eslint-disable no-script-url */ 112 | if (href.indexOf('javascript:') === 0) return 113 | /* eslint-enable no-script-url */ 114 | 115 | return true 116 | } 117 | 118 | /** 119 | * Event button. 120 | */ 121 | 122 | function which (e) { 123 | e = e || window.event 124 | return e.which === null ? e.button : e.which 125 | } 126 | -------------------------------------------------------------------------------- /lib/locations/browser.js: -------------------------------------------------------------------------------- 1 | import { extend } from '../dash' 2 | import LocationBar from 'location-bar' 3 | 4 | export default BrowserLocation 5 | 6 | function BrowserLocation (options) { 7 | this.path = options.path || '' 8 | 9 | this.options = extend({ 10 | pushState: false, 11 | root: '/' 12 | }, options) 13 | 14 | // we're using the location-bar module for actual 15 | // URL management 16 | let self = this 17 | this.locationBar = new LocationBar() 18 | this.locationBar.onChange(function (path) { 19 | self.handleURL('/' + (path || '')) 20 | }) 21 | 22 | this.locationBar.start(extend({}, options)) 23 | } 24 | 25 | /** 26 | * Check if we're actually using pushState. For browsers 27 | * that don't support it this would return false since 28 | * it would fallback to using hashState / polling 29 | * @return {Bool} 30 | */ 31 | 32 | BrowserLocation.prototype.usesPushState = function () { 33 | return this.options.pushState && this.locationBar.hasPushState() 34 | } 35 | 36 | /** 37 | * Get the current URL 38 | */ 39 | 40 | BrowserLocation.prototype.getURL = function () { 41 | return this.path 42 | } 43 | 44 | /** 45 | * Set the current URL without triggering any events 46 | * back to the router. Add a new entry in browser's history. 47 | */ 48 | 49 | BrowserLocation.prototype.setURL = function (path, options) { 50 | if (this.path !== path) { 51 | this.path = path 52 | this.locationBar.update(path, extend({trigger: true}, options)) 53 | } 54 | } 55 | 56 | /** 57 | * Set the current URL without triggering any events 58 | * back to the router. Replace the latest entry in broser's history. 59 | */ 60 | 61 | BrowserLocation.prototype.replaceURL = function (path, options) { 62 | if (this.path !== path) { 63 | this.path = path 64 | this.locationBar.update(path, extend({trigger: true, replace: true}, options)) 65 | } 66 | } 67 | 68 | /** 69 | * Setup a URL change handler 70 | * @param {Function} callback 71 | */ 72 | BrowserLocation.prototype.onChange = function (callback) { 73 | this.changeCallback = callback 74 | } 75 | 76 | /** 77 | * Given a path, generate a URL appending root 78 | * if pushState is used and # if hash state is used 79 | */ 80 | BrowserLocation.prototype.formatURL = function (path) { 81 | if (this.locationBar.hasPushState()) { 82 | let rootURL = this.options.root 83 | if (path !== '') { 84 | rootURL = rootURL.replace(/\/$/, '') 85 | } 86 | return rootURL + path 87 | } else { 88 | if (path[0] === '/') { 89 | path = path.substr(1) 90 | } 91 | return '#' + path 92 | } 93 | } 94 | 95 | /** 96 | * When we use pushState with a custom root option, 97 | * we need to take care of removingRoot at certain points. 98 | * Specifically 99 | * - browserLocation.update() can be called with the full URL by router 100 | * - LocationBar expects all .update() calls to be called without root 101 | * - this method is public so that we could dispatch URLs without root in router 102 | */ 103 | BrowserLocation.prototype.removeRoot = function (url) { 104 | if (this.options.pushState && this.options.root && this.options.root !== '/') { 105 | return url.replace(this.options.root, '') 106 | } else { 107 | return url 108 | } 109 | } 110 | 111 | /** 112 | * Stop listening to URL changes and link clicks 113 | */ 114 | BrowserLocation.prototype.destroy = function () { 115 | this.locationBar.stop() 116 | } 117 | 118 | /** 119 | initially, the changeCallback won't be defined yet, but that's good 120 | because we dont' want to kick off routing right away, the router 121 | does that later by manually calling this handleURL method with the 122 | url it reads of the location. But it's important this is called 123 | first by Backbone, because we wanna set a correct this.path value 124 | 125 | @private 126 | */ 127 | BrowserLocation.prototype.handleURL = function (url) { 128 | this.path = url 129 | if (this.changeCallback) { 130 | this.changeCallback(url) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/locations/memory.js: -------------------------------------------------------------------------------- 1 | import { extend } from '../dash' 2 | 3 | export default MemoryLocation 4 | 5 | function MemoryLocation (options) { 6 | this.path = options.path || '' 7 | } 8 | 9 | MemoryLocation.prototype.getURL = function () { 10 | return this.path 11 | } 12 | 13 | MemoryLocation.prototype.setURL = function (path, options) { 14 | if (this.path !== path) { 15 | this.path = path 16 | this.handleURL(this.getURL(), options) 17 | } 18 | } 19 | 20 | MemoryLocation.prototype.replaceURL = function (path, options) { 21 | if (this.path !== path) { 22 | this.setURL(path, options) 23 | } 24 | } 25 | 26 | MemoryLocation.prototype.onChange = function (callback) { 27 | this.changeCallback = callback 28 | } 29 | 30 | MemoryLocation.prototype.handleURL = function (url, options) { 31 | this.path = url 32 | options = extend({trigger: true}, options) 33 | if (this.changeCallback && options.trigger) { 34 | this.changeCallback(url) 35 | } 36 | } 37 | 38 | MemoryLocation.prototype.usesPushState = function () { 39 | return false 40 | } 41 | 42 | MemoryLocation.prototype.removeRoot = function (url) { 43 | return url 44 | } 45 | 46 | MemoryLocation.prototype.formatURL = function (url) { 47 | return url 48 | } 49 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | export default function createLogger (log, options) { 2 | options = options || {} 3 | // falsy means no logging 4 | if (!log) return () => {} 5 | // custom logging function 6 | if (log !== true) return log 7 | // true means use the default logger - console 8 | let fn = options.error ? console.error : console.info 9 | return function () { 10 | fn.apply(console, arguments) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/path.js: -------------------------------------------------------------------------------- 1 | import invariant from './invariant' 2 | import pathToRegexp from 'path-to-regexp' 3 | 4 | let paramInjectMatcher = /:([a-zA-Z_$][a-zA-Z0-9_$?]*[?+*]?)/g 5 | let specialParamChars = /[+*?]$/g 6 | let queryMatcher = /\?(.+)/ 7 | 8 | let _compiledPatterns = {} 9 | 10 | function compilePattern (pattern) { 11 | if (!(pattern in _compiledPatterns)) { 12 | let paramNames = [] 13 | let re = pathToRegexp(pattern, paramNames) 14 | 15 | _compiledPatterns[pattern] = { 16 | matcher: re, 17 | paramNames: paramNames.map(p => p.name) 18 | } 19 | } 20 | 21 | return _compiledPatterns[pattern] 22 | } 23 | 24 | let Path = { 25 | /** 26 | * Returns true if the given path is absolute. 27 | */ 28 | isAbsolute: function (path) { 29 | return path.charAt(0) === '/' 30 | }, 31 | 32 | /** 33 | * Joins two URL paths together. 34 | */ 35 | join: function (a, b) { 36 | return a.replace(/\/*$/, '/') + b 37 | }, 38 | 39 | /** 40 | * Returns an array of the names of all parameters in the given pattern. 41 | */ 42 | extractParamNames: function (pattern) { 43 | return compilePattern(pattern).paramNames 44 | }, 45 | 46 | /** 47 | * Extracts the portions of the given URL path that match the given pattern 48 | * and returns an object of param name => value pairs. Returns null if the 49 | * pattern does not match the given path. 50 | */ 51 | extractParams: function (pattern, path) { 52 | let cp = compilePattern(pattern) 53 | let matcher = cp.matcher 54 | let paramNames = cp.paramNames 55 | let match = path.match(matcher) 56 | 57 | if (!match) { 58 | return null 59 | } 60 | 61 | let params = {} 62 | 63 | paramNames.forEach(function (paramName, index) { 64 | params[paramName] = match[index + 1] && decodeURIComponent(match[index + 1]) 65 | }) 66 | 67 | return params 68 | }, 69 | 70 | /** 71 | * Returns a version of the given route path with params interpolated. Throws 72 | * if there is a dynamic segment of the route path for which there is no param. 73 | */ 74 | injectParams: function (pattern, params) { 75 | params = params || {} 76 | 77 | return pattern.replace(paramInjectMatcher, function (match, param) { 78 | let paramName = param.replace(specialParamChars, '') 79 | let lastChar = param.slice(-1) 80 | 81 | // If param is optional don't check for existence 82 | if (lastChar === '?' || lastChar === '*') { 83 | if (params[paramName] == null) { 84 | return '' 85 | } 86 | } else { 87 | invariant( 88 | params[paramName] != null, 89 | "Missing '%s' parameter for path '%s'", 90 | paramName, pattern 91 | ) 92 | } 93 | 94 | let paramValue = encodeURIComponent(params[paramName]) 95 | if (lastChar === '*' || lastChar === '+') { 96 | // restore / for splats 97 | paramValue = paramValue.replace('%2F', '/') 98 | } 99 | return paramValue 100 | }) 101 | }, 102 | 103 | /** 104 | * Returns an object that is the result of parsing any query string contained 105 | * in the given path, null if the path contains no query string. 106 | */ 107 | extractQuery: function (qs, path) { 108 | let match = path.match(queryMatcher) 109 | return match && qs.parse(match[1]) 110 | }, 111 | 112 | /** 113 | * Returns a version of the given path with the parameters in the given 114 | * query merged into the query string. 115 | */ 116 | withQuery: function (qs, path, query) { 117 | let queryString = qs.stringify(query, { indices: false }) 118 | 119 | if (queryString) { 120 | return Path.withoutQuery(path) + '?' + queryString 121 | } 122 | 123 | return path 124 | }, 125 | 126 | /** 127 | * Returns a version of the given path without the query string. 128 | */ 129 | withoutQuery: function (path) { 130 | return path.replace(queryMatcher, '') 131 | } 132 | } 133 | 134 | export default Path 135 | -------------------------------------------------------------------------------- /lib/qs.js: -------------------------------------------------------------------------------- 1 | export default { 2 | parse (querystring) { 3 | return querystring.split('&').reduce((acc, pair) => { 4 | let parts = pair.split('=') 5 | acc[parts[0]] = decodeURIComponent(parts[1]) 6 | return acc 7 | }, {}) 8 | }, 9 | 10 | stringify (params) { 11 | return Object.keys(params).reduce((acc, key) => { 12 | if (params[key] !== undefined) { 13 | acc.push(key + '=' + encodeURIComponent(params[key])) 14 | } 15 | return acc 16 | }, []).join('&') 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/router.js: -------------------------------------------------------------------------------- 1 | import { pick, clone, extend, isEqual, isString, find } from './dash' 2 | import dsl from './dsl' 3 | import Path from './path' 4 | import invariant from './invariant' 5 | import BrowserLocation from './locations/browser' 6 | import MemoryLocation from './locations/memory' 7 | import transition from './transition' 8 | import { intercept } from './links' 9 | import createLogger from './logger' 10 | import qs from './qs' 11 | 12 | function Cherrytree () { 13 | this.initialize.apply(this, arguments) 14 | } 15 | 16 | /** 17 | * The actual constructor 18 | * @param {Object} options 19 | */ 20 | Cherrytree.prototype.initialize = function (options) { 21 | this.nextId = 1 22 | this.state = {} 23 | this.middleware = [] 24 | this.options = extend({ 25 | location: 'browser', 26 | interceptLinks: true, 27 | logError: true, 28 | Promise: Promise, 29 | qs: qs 30 | }, options) 31 | this.log = createLogger(this.options.log) 32 | this.logError = createLogger(this.options.logError, { error: true }) 33 | 34 | invariant(typeof this.options.Promise === 'function', 35 | 'Cherrytree requires an ES6 Promise implementation, ' + 36 | 'either as an explicit option or a global Promise') 37 | } 38 | 39 | /** 40 | * Add a middleware 41 | * @param {Function} middleware 42 | * @return {Object} router 43 | * @api public 44 | */ 45 | Cherrytree.prototype.use = function (middleware) { 46 | this.middleware.push(middleware) 47 | return this 48 | } 49 | 50 | /** 51 | * Add the route map 52 | * @param {Function} routes 53 | * @return {Object} router 54 | * @api public 55 | */ 56 | Cherrytree.prototype.map = function (routes) { 57 | // create the route tree 58 | this.routes = dsl(routes) 59 | 60 | // create the matcher list, which is like a flattened 61 | // list of routes = a list of all branches of the route tree 62 | let matchers = this.matchers = [] 63 | // keep track of whether duplicate paths have been created, 64 | // in which case we'll warn the dev 65 | let dupes = {} 66 | // keep track of abstract routes to build index route forwarding 67 | let abstracts = {} 68 | 69 | eachBranch({routes: this.routes}, [], function (routes) { 70 | // concatenate the paths of the list of routes 71 | let path = routes.reduce(function (memo, r) { 72 | // reset if there's a leading slash, otherwise concat 73 | // and keep resetting the trailing slash 74 | return (r.path[0] === '/' ? r.path : memo + '/' + r.path).replace(/\/$/, '') 75 | }, '') 76 | // ensure we have a leading slash 77 | if (path === '') { 78 | path = '/' 79 | } 80 | 81 | let lastRoute = routes[routes.length - 1] 82 | 83 | if (lastRoute.options.abstract) { 84 | abstracts[path] = lastRoute.name 85 | return 86 | } 87 | 88 | // register routes 89 | matchers.push({ 90 | routes: routes, 91 | name: lastRoute.name, 92 | path: path 93 | }) 94 | 95 | // dupe detection 96 | if (dupes[path]) { 97 | throw new Error('Routes ' + dupes[path] + ' and ' + lastRoute.name + 98 | ' have the same url path \'' + path + '\'') 99 | } 100 | dupes[path] = lastRoute.name 101 | }) 102 | 103 | // check if there is an index route for each abstract route 104 | Object.keys(abstracts).forEach(function (path) { 105 | let matcher 106 | if (!dupes[path]) return 107 | 108 | matchers.some(function (m) { 109 | if (m.path === path) { 110 | matcher = m 111 | return true 112 | } 113 | }) 114 | 115 | matchers.push({ 116 | routes: matcher.routes, 117 | name: abstracts[path], 118 | path: path 119 | }) 120 | }) 121 | 122 | function eachBranch (node, memo, fn) { 123 | node.routes.forEach(function (route) { 124 | fn(memo.concat(route)) 125 | 126 | if (route.routes.length) { 127 | eachBranch(route, memo.concat(route), fn) 128 | } 129 | }) 130 | } 131 | 132 | return this 133 | } 134 | 135 | /** 136 | * Starts listening to the location changes. 137 | * @param {Object} location (optional) 138 | * @return {Promise} initial transition 139 | * 140 | * @api public 141 | */ 142 | Cherrytree.prototype.listen = function (path) { 143 | let location = this.location = this.createLocation(path || '') 144 | // setup the location onChange handler 145 | location.onChange((url) => this.dispatch(url)) 146 | // start intercepting links 147 | if (this.options.interceptLinks && location.usesPushState()) { 148 | this.interceptLinks() 149 | } 150 | // and also kick off the initial transition 151 | return this.dispatch(location.getURL()) 152 | } 153 | 154 | /** 155 | * Transition to a different route. Passe in url or a route name followed by params and query 156 | * @param {String} url url or route name 157 | * @param {Object} params Optional 158 | * @param {Object} query Optional 159 | * @return {Object} transition 160 | * 161 | * @api public 162 | */ 163 | Cherrytree.prototype.transitionTo = function (...args) { 164 | if (this.state.activeTransition) { 165 | return this.replaceWith.apply(this, args) 166 | } 167 | return this.doTransition('setURL', args) 168 | } 169 | 170 | /** 171 | * Like transitionTo, but doesn't leave an entry in the browser's history, 172 | * so clicking back will skip this route 173 | * @param {String} url url or route name followed by params and query 174 | * @param {Object} params Optional 175 | * @param {Object} query Optional 176 | * @return {Object} transition 177 | * 178 | * @api public 179 | */ 180 | Cherrytree.prototype.replaceWith = function (...args) { 181 | return this.doTransition('replaceURL', args) 182 | } 183 | 184 | /** 185 | * Create an href 186 | * @param {String} name target route name 187 | * @param {Object} params 188 | * @param {Object} query 189 | * @return {String} href 190 | * 191 | * @api public 192 | */ 193 | Cherrytree.prototype.generate = function (name, params, query) { 194 | invariant(this.location, 'call .listen() before using .generate()') 195 | let matcher 196 | 197 | params = params || {} 198 | query = query || {} 199 | 200 | this.matchers.forEach(function (m) { 201 | if (m.name === name) { 202 | matcher = m 203 | } 204 | }) 205 | 206 | if (!matcher) { 207 | throw new Error('No route is named ' + name) 208 | } 209 | 210 | // this might be a dangerous feature, although it's useful in practise 211 | // if some params are not passed into the generate call, they're populated 212 | // based on the current state or on the currently active transition. 213 | // Consider removing this.. since the users can opt into this behaviour, by 214 | // reaching out to the router.state if that's what they want. 215 | let currentParams = clone(this.state.params || {}) 216 | if (this.state.activeTransition) { 217 | currentParams = clone(this.state.activeTransition.params || {}) 218 | } 219 | params = extend(currentParams, params) 220 | 221 | let url = Path.withQuery(this.options.qs, Path.injectParams(matcher.path, params), query) 222 | return this.location.formatURL(url) 223 | } 224 | 225 | /** 226 | * Stop listening to URL changes 227 | * @api public 228 | */ 229 | Cherrytree.prototype.destroy = function () { 230 | if (this.location && this.location.destroy) { 231 | this.location.destroy() 232 | } 233 | if (this.disposeIntercept) { 234 | this.disposeIntercept() 235 | } 236 | if (this.state.activeTransition) { 237 | this.state.activeTransition.cancel() 238 | } 239 | this.state = {} 240 | } 241 | 242 | /** 243 | * Check if the given route/params/query combo is active 244 | * @param {String} name target route name 245 | * @param {Object} params 246 | * @param {Object} query 247 | * @return {Boolean} 248 | * 249 | * @api public 250 | */ 251 | Cherrytree.prototype.isActive = function (name, params, query) { 252 | params = params || {} 253 | query = query || {} 254 | 255 | let activeRoutes = this.state.routes || [] 256 | let activeParams = this.state.params || {} 257 | let activeQuery = this.state.query || {} 258 | 259 | let isActive = !!find(activeRoutes, route => route.name === name) 260 | isActive = isActive && !!Object.keys(params).every(key => activeParams[key] === params[key]) 261 | isActive = isActive && !!Object.keys(query).every(key => activeQuery[key] === query[key]) 262 | 263 | return isActive 264 | } 265 | 266 | /** 267 | * @api private 268 | */ 269 | Cherrytree.prototype.doTransition = function (method, params) { 270 | let previousUrl = this.location.getURL() 271 | 272 | let url = params[0] 273 | if (url[0] !== '/') { 274 | url = this.generate.apply(this, params) 275 | url = url.replace(/^#/, '/') 276 | } 277 | 278 | if (this.options.pushState) { 279 | url = this.location.removeRoot(url) 280 | } 281 | 282 | let transition = this.dispatch(url) 283 | 284 | transition.catch((err) => { 285 | if (err && err.type === 'TransitionCancelled') { 286 | // reset the URL in case the transition has been cancelled 287 | this.location.replaceURL(previousUrl, {trigger: false}) 288 | } 289 | return err 290 | }) 291 | 292 | this.location[method](url, {trigger: false}) 293 | 294 | return transition 295 | } 296 | 297 | /** 298 | * Match the path against the routes 299 | * @param {String} path 300 | * @return {Object} the list of matching routes and params 301 | * 302 | * @api private 303 | */ 304 | Cherrytree.prototype.match = function (path) { 305 | path = (path || '').replace(/\/$/, '') || '/' 306 | let params 307 | let routes = [] 308 | let pathWithoutQuery = Path.withoutQuery(path) 309 | let qs = this.options.qs 310 | this.matchers.some(function (matcher) { 311 | params = Path.extractParams(matcher.path, pathWithoutQuery) 312 | if (params) { 313 | routes = matcher.routes 314 | return true 315 | } 316 | }) 317 | return { 318 | routes: routes.map(descriptor), 319 | params: params || {}, 320 | pathname: pathWithoutQuery, 321 | query: Path.extractQuery(qs, path) || {} 322 | } 323 | 324 | // clone the data (only a shallow clone of options) 325 | // to make sure the internal route store is not mutated 326 | // by the middleware. The middleware can mutate data 327 | // before it gets passed into the next middleware, but 328 | // only within the same transition. New transitions 329 | // will get to use pristine data. 330 | function descriptor (route) { 331 | return { 332 | name: route.name, 333 | path: route.path, 334 | params: pick(params, Path.extractParamNames(route.path)), 335 | options: clone(route.options) 336 | } 337 | } 338 | } 339 | 340 | Cherrytree.prototype.dispatch = function (path) { 341 | let match = this.match(path) 342 | let query = match.query 343 | let pathname = match.pathname 344 | 345 | let activeTransition = this.state.activeTransition 346 | 347 | // if we already have an active transition with all the same 348 | // params - return that and don't do anything else 349 | if (activeTransition && 350 | activeTransition.pathname === pathname && 351 | isEqual(activeTransition.query, query)) { 352 | return activeTransition 353 | } 354 | 355 | // otherwise, cancel the active transition since we're 356 | // redirecting (or initiating a brand new transition) 357 | if (activeTransition) { 358 | let err = new Error('TransitionRedirected') 359 | err.type = 'TransitionRedirected' 360 | err.nextPath = path 361 | activeTransition.cancel(err) 362 | } 363 | 364 | // if there is no active transition, check if 365 | // this is a noop transition, in which case, return 366 | // a transition to respect the function signature, 367 | // but don't actually run any of the middleware 368 | if (!activeTransition) { 369 | if (this.state.pathname === pathname && 370 | isEqual(this.state.query, query)) { 371 | return transition({ 372 | id: this.nextId++, 373 | path: path, 374 | match: match, 375 | noop: true, 376 | router: this 377 | }, this.options.Promise) 378 | } 379 | } 380 | 381 | let t = transition({ 382 | id: this.nextId++, 383 | path: path, 384 | match: match, 385 | router: this 386 | }, this.options.Promise) 387 | 388 | this.state.activeTransition = t 389 | 390 | return t 391 | } 392 | 393 | /** 394 | * Create the default location. 395 | * This is used when no custom location is passed to 396 | * the listen call. 397 | * @return {Object} location 398 | * 399 | * @api private 400 | */ 401 | Cherrytree.prototype.createLocation = function (path) { 402 | let location = this.options.location 403 | if (!isString(location)) { 404 | return location 405 | } 406 | if (location === 'browser') { 407 | return new BrowserLocation(pick(this.options, ['pushState', 'root'])) 408 | } else if (location === 'memory') { 409 | return new MemoryLocation({path}) 410 | } else { 411 | throw new Error('Location can be `browser`, `memory` or a custom implementation') 412 | } 413 | } 414 | 415 | /** 416 | * When using pushState, it's important to setup link interception 417 | * because all link clicks should be handled via the router instead of 418 | * browser reloading the page 419 | */ 420 | Cherrytree.prototype.interceptLinks = function () { 421 | let clickHandler = typeof this.options.interceptLinks === 'function' 422 | ? this.options.interceptLinks 423 | : defaultClickHandler 424 | this.disposeIntercept = intercept((event, link) => clickHandler(event, link, this)) 425 | 426 | function defaultClickHandler (event, link, router) { 427 | event.preventDefault() 428 | router.transitionTo(router.location.removeRoot(link.getAttribute('href'))) 429 | } 430 | } 431 | 432 | export default function cherrytree (options) { 433 | return new Cherrytree(options) 434 | } 435 | 436 | cherrytree.BrowserLocation = BrowserLocation 437 | cherrytree.MemoryLocation = MemoryLocation 438 | -------------------------------------------------------------------------------- /lib/transition.js: -------------------------------------------------------------------------------- 1 | import { clone } from './dash' 2 | import invariant from './invariant' 3 | 4 | export default function transition (options, Promise) { 5 | options = options || {} 6 | 7 | let router = options.router 8 | let log = router.log 9 | let logError = router.logError 10 | 11 | let path = options.path 12 | let match = options.match 13 | let routes = match.routes 14 | let params = match.params 15 | let pathname = match.pathname 16 | let query = match.query 17 | 18 | let id = options.id 19 | let startTime = Date.now() 20 | log('---') 21 | log('Transition #' + id, 'to', path) 22 | log('Transition #' + id, 'routes:', routes.map(r => r.name)) 23 | log('Transition #' + id, 'params:', params) 24 | log('Transition #' + id, 'query:', query) 25 | 26 | // create the transition promise 27 | let resolve, reject 28 | let promise = new Promise(function (res, rej) { 29 | resolve = res 30 | reject = rej 31 | }) 32 | 33 | // 1. make transition errors loud 34 | // 2. by adding this handler we make sure 35 | // we don't trigger the default 'Potentially 36 | // unhandled rejection' for cancellations 37 | promise.then(function () { 38 | log('Transition #' + id, 'completed in', (Date.now() - startTime) + 'ms') 39 | }).catch(function (err) { 40 | if (err.type !== 'TransitionRedirected' && err.type !== 'TransitionCancelled') { 41 | log('Transition #' + id, 'FAILED') 42 | logError(err.stack) 43 | } 44 | }) 45 | 46 | let cancelled = false 47 | 48 | let transition = { 49 | id: id, 50 | prev: { 51 | routes: clone(router.state.routes) || [], 52 | path: router.state.path || '', 53 | pathname: router.state.pathname || '', 54 | params: clone(router.state.params) || {}, 55 | query: clone(router.state.query) || {} 56 | }, 57 | routes: clone(routes), 58 | path: path, 59 | pathname: pathname, 60 | params: clone(params), 61 | query: clone(query), 62 | redirectTo: function () { 63 | return router.transitionTo.apply(router, arguments) 64 | }, 65 | retry: function () { 66 | return router.transitionTo(path) 67 | }, 68 | cancel: function (err) { 69 | if (router.state.activeTransition !== transition) { 70 | return 71 | } 72 | 73 | if (transition.isCancelled) { 74 | return 75 | } 76 | 77 | router.state.activeTransition = null 78 | transition.isCancelled = true 79 | cancelled = true 80 | 81 | if (!err) { 82 | err = new Error('TransitionCancelled') 83 | err.type = 'TransitionCancelled' 84 | } 85 | if (err.type === 'TransitionCancelled') { 86 | log('Transition #' + id, 'cancelled') 87 | } 88 | if (err.type === 'TransitionRedirected') { 89 | log('Transition #' + id, 'redirected') 90 | } 91 | 92 | reject(err) 93 | }, 94 | followRedirects: function () { 95 | return promise['catch'](function (reason) { 96 | if (router.state.activeTransition) { 97 | return router.state.activeTransition.followRedirects() 98 | } 99 | return Promise.reject(reason) 100 | }) 101 | }, 102 | 103 | then: promise.then.bind(promise), 104 | catch: promise.catch.bind(promise) 105 | } 106 | 107 | // here we handle calls to all of the middlewares 108 | function callNext (i, prevResult) { 109 | let middlewareName 110 | // if transition has been cancelled - nothing left to do 111 | if (cancelled) { 112 | return 113 | } 114 | // done 115 | if (i < router.middleware.length) { 116 | middlewareName = router.middleware[i].name || 'anonymous' 117 | log('Transition #' + id, 'resolving middleware:', middlewareName) 118 | let middlewarePromise 119 | try { 120 | middlewarePromise = router.middleware[i](transition, prevResult) 121 | invariant(transition !== middlewarePromise, 'Middleware %s returned a transition which resulted in a deadlock', middlewareName) 122 | } catch (err) { 123 | router.state.activeTransition = null 124 | return reject(err) 125 | } 126 | Promise.resolve(middlewarePromise) 127 | .then(function (result) { 128 | callNext(i + 1, result) 129 | }) 130 | .catch(function (err) { 131 | log('Transition #' + id, 'resolving middleware:', middlewareName, 'FAILED') 132 | router.state.activeTransition = null 133 | reject(err) 134 | }) 135 | } else { 136 | router.state = { 137 | activeTransition: null, 138 | routes: routes, 139 | path: path, 140 | pathname: pathname, 141 | params: params, 142 | query: query 143 | } 144 | resolve() 145 | } 146 | } 147 | 148 | if (!options.noop) { 149 | Promise.resolve().then(() => callNext(0)) 150 | } else { 151 | resolve() 152 | } 153 | 154 | if (options.noop) { 155 | transition.noop = true 156 | } 157 | 158 | return transition 159 | } 160 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QubitProducts/cherrytree/23bcc7a9f625e1eb4beca7f089ac6291738bc4e6/logo.png -------------------------------------------------------------------------------- /logo.pxm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QubitProducts/cherrytree/23bcc7a9f625e1eb4beca7f089ac6291738bc4e6/logo.pxm -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cherrytree", 3 | "version": "2.4.1", 4 | "description": "Cherrytree - a flexible hierarchical client side router", 5 | "main": "index", 6 | "scripts": { 7 | "build": "./tasks/build.sh", 8 | "release": "release --build", 9 | "test": "standard | snazzy && karma start --single-run", 10 | "test-no-coverage": "standard | snazzy && karma start --single-run --no-coverage", 11 | "watch": "DEBUG=1 webpack -w index.js build/standalone.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/QubitProducts/cherrytree.git" 16 | }, 17 | "author": "Karolis Narkevicius ", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/QubitProducts/cherrytree/issues" 21 | }, 22 | "dependencies": { 23 | "location-bar": "^3.0.1", 24 | "path-to-regexp": "^1.0.3" 25 | }, 26 | "keywords": [ 27 | "router", 28 | "history", 29 | "browser", 30 | "pushState", 31 | "hierarchical", 32 | "nested" 33 | ], 34 | "devDependencies": { 35 | "babel": "^6.5.2", 36 | "babel-cli": "^6.18.0", 37 | "babel-core": "^6.21.0", 38 | "babel-eslint": "^7.1.1", 39 | "babel-loader": "^6.2.10", 40 | "babel-plugin-transform-async-to-generator": "^6.16.0", 41 | "babel-plugin-transform-runtime": "^6.15.0", 42 | "babel-preset-es2015": "^6.18.0", 43 | "babel-runtime": "^6.20.0", 44 | "bro-size": "^1.0.0", 45 | "es6-promise": "^3.0.2", 46 | "istanbul-instrumenter-loader": "^0.1.3", 47 | "jquery": "^3.3.1", 48 | "karma": "^0.13.9", 49 | "karma-chrome-launcher": "^0.2.0", 50 | "karma-cli": "^0.1.0", 51 | "karma-coverage": "^0.5.0", 52 | "karma-effroi": "0.0.0", 53 | "karma-firefox-launcher": "~0.1.6", 54 | "karma-mocha": "^0.2.0", 55 | "karma-sauce-launcher": "^0.2.10", 56 | "karma-sourcemap-loader": "^0.3.4", 57 | "karma-webpack": "^1.8.1", 58 | "kn-release": "^1.0.1", 59 | "mocha": "^2.2.0", 60 | "referee": "^1.1.1", 61 | "snazzy": "^2.0.1", 62 | "standard": "^5.1.1", 63 | "webpack": "^1.14.0", 64 | "webpack-dev-server": "^1.6.6", 65 | "yargs": "^3.23.0" 66 | }, 67 | "standard": { 68 | "parser": "babel-eslint", 69 | "ignore": [ 70 | "examples/**/dist", 71 | "docs/**", 72 | "build/**" 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tasks/build.sh: -------------------------------------------------------------------------------- 1 | rm -rf build 2 | 3 | # npm CJS 4 | babel --presets=es2015 -d build/lib ./lib 5 | cp README.md build 6 | cp CHANGELOG.md build 7 | cp index.js build 8 | cp package.json build 9 | 10 | # standalone UMD 11 | webpack index.js build/standalone.js 12 | 13 | echo "\nnpm build including deps is\n `bro-size build`" 14 | echo "\nnpm build excluding deps is\n `bro-size build -u location-bar -u qs -u path-to-regexp`" 15 | echo "\nstandalone build including deps is\n `bro-size build/standalone.js`" 16 | -------------------------------------------------------------------------------- /tests/functional/pushStateTest.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import {assert} from 'referee' 3 | import fakeHistory from '../lib/fakeHistory' 4 | import TestApp from './testApp' 5 | let {suite, test, beforeEach, afterEach} = window 6 | let app, router, history 7 | 8 | // This is to avoid running these tests in IE9 in CI 9 | if (window.history && window.history.pushState) { 10 | suite('Cherrytree app using pushState') 11 | 12 | beforeEach(() => { 13 | window.location.hash = '' 14 | app = new TestApp({ 15 | pushState: true, 16 | root: '/app' 17 | }) 18 | router = app.router 19 | return app.start().then(() => history = fakeHistory(router.location)) 20 | }) 21 | 22 | afterEach(() => { 23 | app.destroy() 24 | history.restore() 25 | }) 26 | 27 | test('transition occurs when location.hash changes', (done) => { 28 | router.use((transition) => { 29 | transition.then(() => { 30 | assert.equals(transition.path, '/about') 31 | assert.equals($('.application .outlet').html(), 'This is about page') 32 | done() 33 | }).catch(done, done) 34 | }) 35 | 36 | history.setURL('/app/about') 37 | }) 38 | 39 | test('programmatic transition via url and route names', async function () { 40 | await router.transitionTo('about') 41 | assert.equals(history.getURL(), '/app/about') 42 | await router.transitionTo('/faq?sortBy=date') 43 | assert.equals(history.getURL(), '/app/faq?sortBy=date') 44 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: date') 45 | await router.transitionTo('faq', {}, { sortBy: 'user' }) 46 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: user') 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /tests/functional/routerTest.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import { Promise } from 'es6-promise' 3 | import { assert } from 'referee' 4 | import TestApp from './testApp' 5 | 6 | let { suite, test, beforeEach, afterEach } = window 7 | let app, router 8 | 9 | suite('Cherrytree app') 10 | 11 | beforeEach(() => { 12 | window.location.hash = '/' 13 | app = new TestApp() 14 | router = app.router 15 | return app.start() 16 | }) 17 | 18 | afterEach(() => { 19 | app.destroy() 20 | }) 21 | 22 | test('transition occurs when location.hash changes', (done) => { 23 | router.use((transition) => { 24 | transition.then(() => { 25 | assert.equals(transition.path, '/about') 26 | assert.equals($('.application .outlet').html(), 'This is about page') 27 | done() 28 | }).catch(done, done) 29 | }) 30 | 31 | window.location.hash = '#about' 32 | }) 33 | 34 | test('programmatic transition via url and route names', async function () { 35 | await router.transitionTo('about') 36 | await router.transitionTo('/faq?sortBy=date') 37 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: date') 38 | await router.transitionTo('faq', {}, { sortBy: 'user' }) 39 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: user') 40 | }) 41 | 42 | test('cancelling and retrying transitions', async function () { 43 | await router.transitionTo('/posts/filter/foo') 44 | assert.equals(router.location.getURL(), '/posts/filter/foo') 45 | var transition = router.transitionTo('about') 46 | transition.cancel() 47 | await transition.catch(() => {}) 48 | assert.equals(router.location.getURL(), '/posts/filter/foo') 49 | 50 | await transition.retry() 51 | assert.equals(router.location.getURL(), '/about') 52 | }) 53 | 54 | test('transition.followRedirects resolves when all of the redirects have finished', async function () { 55 | var transition 56 | 57 | await router.transitionTo('application') 58 | // initiate a transition 59 | transition = router.transitionTo('/posts/filter/foo') 60 | // and a redirect 61 | router.transitionTo('/about') 62 | 63 | // if followRedirects is not used - the original transition is rejected 64 | var rejected = false 65 | await transition.catch(() => rejected = true) 66 | assert(rejected) 67 | 68 | await router.transitionTo('application') 69 | // initiate a transition 70 | var t = router.transitionTo('/posts/filter/foo') 71 | // and a redirect, this time using `redirectTo` 72 | t.redirectTo('/about') 73 | 74 | // when followRedirects is used - the promise is only 75 | // resolved when both transitions finish 76 | await transition.followRedirects() 77 | assert.equals(router.location.getURL(), '/about') 78 | }) 79 | 80 | test('transition.followRedirects is rejected if transition fails', async function () { 81 | var transition 82 | 83 | // silence the errors for the tests 84 | router.logError = () => {} 85 | 86 | // initiate a transition 87 | transition = router.transitionTo('/posts/filter/foo') 88 | // install a breaking middleware 89 | router.use(() => { 90 | throw new Error('middleware error') 91 | }) 92 | // and a redirect 93 | router.transitionTo('/about') 94 | 95 | var rejected = false 96 | await transition.followRedirects().catch((err) => rejected = err.message) 97 | assert.equals(rejected, 'middleware error') 98 | }) 99 | 100 | test('transition.followRedirects is rejected if transition fails asynchronously', async function () { 101 | var transition 102 | 103 | // silence the errors for the tests 104 | router.logError = () => {} 105 | 106 | // initiate a transition 107 | transition = router.transitionTo('/posts/filter/foo') 108 | // install a breaking middleware 109 | router.use(() => { 110 | return Promise.reject(new Error('middleware promise error')) 111 | }) 112 | // and a redirect 113 | router.transitionTo('/about') 114 | 115 | var rejected = false 116 | await transition.followRedirects().catch((err) => rejected = err.message) 117 | assert.equals(rejected, 'middleware promise error') 118 | }) 119 | 120 | test.skip('cancelling transition does not add a history entry', async function () { 121 | // we start of at faq 122 | await router.transitionTo('faq') 123 | // then go to posts.filter 124 | await router.transitionTo('posts.filter', {filterId: 'foo'}) 125 | assert.equals(window.location.hash, '#posts/filter/foo') 126 | 127 | // now attempt to transition to about and cancel 128 | var transition = router.transitionTo('/about') 129 | transition.cancel() 130 | await transition.catch(() => {}) 131 | 132 | // the url is still posts.filter 133 | assert.equals(window.location.hash, '#posts/filter/foo') 134 | 135 | // going back should now take as to faq 136 | await new Promise((resolve, reject) => { 137 | router.use((transition) => { 138 | transition.then(() => { 139 | assert.equals(window.location.hash, '#faq') 140 | resolve() 141 | }).catch(reject) 142 | }) 143 | window.history.back() 144 | }) 145 | }) 146 | 147 | test('navigating around the app', async function () { 148 | assert.equals($('.application .outlet').html(), 'Welcome to this application') 149 | 150 | await router.transitionTo('about') 151 | assert.equals($('.application .outlet').html(), 'This is about page') 152 | 153 | await router.transitionTo('/faq?sortBy=date') 154 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: date') 155 | 156 | await router.transitionTo('faq', {}, { sortBy: 'user' }) 157 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: user') 158 | 159 | // we can also change the url directly to cause another transition to happen 160 | await new Promise(function (resolve) { 161 | router.use(resolve) 162 | window.location.hash = '#posts/filter/mine' 163 | }) 164 | assert.equals($('.application .outlet').html(), 'My posts...') 165 | 166 | await new Promise(function (resolve) { 167 | router.use(resolve) 168 | window.location.hash = '#posts/filter/foo' 169 | }) 170 | assert.equals($('.application .outlet').html(), 'Filter not found') 171 | }) 172 | 173 | test('url behaviour during transitions', async function () { 174 | assert.equals(window.location.hash, '#/') 175 | let transition = router.transitionTo('about') 176 | assert.equals(window.location.hash, '#about') 177 | await transition 178 | assert.equals(window.location.hash, '#about') 179 | // would be cool to test history.back() here 180 | // but in IE it reloads the karma iframe, so let's 181 | // use a regular location.hash assignment instead 182 | // window.history.back() 183 | window.location.hash = '#/' 184 | await new Promise((resolve) => { 185 | router.use((transition) => { 186 | assert.equals(window.location.hash, '#/') 187 | resolve() 188 | }) 189 | }) 190 | }) 191 | 192 | test('url behaviour during failed transitions', async function () { 193 | router.logError = () => {} 194 | await router.transitionTo('about') 195 | await new Promise((resolve, reject) => { 196 | // setup a middleware that will fail 197 | router.use((transition) => { 198 | // but catch the error 199 | transition.catch((err) => { 200 | assert.equals(err.message, 'failed') 201 | assert.equals(window.location.hash, '#faq') 202 | resolve() 203 | }).catch(reject) 204 | throw new Error('failed') 205 | }) 206 | router.transitionTo('faq') 207 | }) 208 | }) 209 | 210 | test('uses a custom provided Promise implementation', async function() { 211 | let called = 0 212 | var LocalPromise = function (fn) { 213 | called++ 214 | return new Promise(fn) 215 | } 216 | let statics = ['reject', 'resolve', 'race', 'all'] 217 | statics.forEach(s => LocalPromise[s] = Promise[s].bind(Promise)) 218 | 219 | app.destroy() 220 | app = new TestApp({ Promise: LocalPromise }) 221 | await app.start() 222 | assert.equals(called, 1) 223 | 224 | await app.router.transitionTo('faq') 225 | assert.equals(called, 2) 226 | }) 227 | -------------------------------------------------------------------------------- /tests/functional/testApp.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import cherrytree from 'cherrytree' 3 | 4 | export default function TestApp (options) { 5 | options = options || {} 6 | 7 | // create the router 8 | var router = this.router = cherrytree(options) 9 | 10 | // provide the route map 11 | router.map(function (route) { 12 | route('application', { path: '/' }, function () { 13 | route('about') 14 | route('faq') 15 | route('posts', function () { 16 | route('posts.popular') 17 | route('posts.filter', { path: 'filter/:filterId' }) 18 | route('posts.show', { path: ':id' }) 19 | }) 20 | }) 21 | }) 22 | 23 | var handlers = {} 24 | 25 | handlers['application'] = { 26 | // this is a cherrytree hook for 'performing' 27 | // actions upon entering this state 28 | activate: function () { 29 | this.$view = $('
    ', { 30 | 'class': 'application', 31 | css: { 32 | margin: '100px', 33 | textAlign: 'center', 34 | border: '10px solid #333' 35 | } 36 | }) 37 | this.$view.html('

    Cherrytree Application

    ') 38 | this.$outlet = this.$view.find('.outlet') 39 | this.$outlet.html('Welcome to this application') 40 | $(document.body).html(this.$view) 41 | } 42 | } 43 | 44 | handlers['about'] = { 45 | activate: function () { 46 | this.parent.$outlet.html('This is about page') 47 | } 48 | } 49 | 50 | handlers['faq'] = { 51 | activate: function (params, query) { 52 | this.parent.$outlet.html('FAQ.') 53 | this.parent.$outlet.append(' Sorted By: ' + query.sortBy) 54 | } 55 | } 56 | 57 | handlers['posts'] = { 58 | activate: function () {} 59 | } 60 | 61 | handlers['posts.filter'] = { 62 | activate: function (params) { 63 | if (params.filterId === 'mine') { 64 | this.parent.parent.$outlet.html('My posts...') 65 | } else { 66 | this.parent.parent.$outlet.html('Filter not found') 67 | } 68 | } 69 | } 70 | 71 | router.use((transition) => { 72 | transition.routes.forEach((route, i) => { 73 | let handler = handlers[route.name] 74 | let parentRoute = transition.routes[i - 1] 75 | handler.parent = parentRoute ? handlers[parentRoute.name] : null 76 | handler.activate(transition.params, transition.query) 77 | }) 78 | }) 79 | } 80 | 81 | TestApp.prototype.start = function () { 82 | return this.router.listen() 83 | } 84 | 85 | TestApp.prototype.destroy = function () { 86 | $(document.body).empty() 87 | return this.router.destroy() 88 | } 89 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | // do the webpack thing 2 | let testsContext = require.context('.', true, /Test$/) 3 | testsContext.keys().forEach(testsContext) 4 | -------------------------------------------------------------------------------- /tests/lib/fakeHistory.js: -------------------------------------------------------------------------------- 1 | export default function fakeHistory (location) { 2 | let history = [] 3 | 4 | var originalPushState = window.history.pushState 5 | window.history.pushState = function (state, title, url) { 6 | history.push(url) 7 | } 8 | 9 | return { 10 | getURL: function getURL () { 11 | return history[history.length - 1] 12 | }, 13 | 14 | /** 15 | * This method relies on deep internals of 16 | * how location-bar is implented, to simulate 17 | * what happens when the URL in the browser 18 | * changes. It might be better to 19 | * a) build functional tests that include a server with real pushState 20 | * b) unit test around this 21 | */ 22 | setURL: function setURL (url) { 23 | // cherrytree + location-bar + window.location 24 | location.locationBar.location = { 25 | pathname: url, 26 | search: '' 27 | } 28 | // 'trigger' a popstate 29 | location.locationBar.checkUrl() 30 | }, 31 | 32 | restore: function restore () { 33 | window.history.pushState = originalPushState 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/unit/dashTest.js: -------------------------------------------------------------------------------- 1 | import { assert, refute } from 'referee' 2 | import { clone, pick, isEqual, extend } from '../../lib/dash' 3 | 4 | let {suite, test} = window 5 | 6 | suite('dash') 7 | 8 | test('clone arrays', () => { 9 | let a = [1, 2, 3] 10 | let b = clone(a) 11 | b.push(4) 12 | assert.equals(a, [1, 2, 3]) 13 | assert.equals(b, [1, 2, 3, 4]) 14 | }) 15 | 16 | test('clone objects', () => { 17 | let a = {a: 1, b: 2} 18 | let b = clone(a) 19 | b.c = 3 20 | assert.equals(a, {a: 1, b: 2}) 21 | assert.equals(b, {a: 1, b: 2, c: 3}) 22 | }) 23 | 24 | test('clone falsy values', () => { 25 | assert.equals(clone(undefined), undefined) 26 | assert.equals(clone(null), null) 27 | assert.equals(clone(false), false) 28 | assert.equals(clone(0), 0) 29 | }) 30 | 31 | test('pick', () => { 32 | assert.equals(pick({a: 1, b: 2, c: 3}, ['a', 'c']), {a: 1, c: 3}) 33 | assert.equals(pick({a: 1}, ['a', 'c']), {a: 1}) 34 | }) 35 | 36 | test('isEqual', () => { 37 | let arr = [] 38 | assert(isEqual({a: 1, b: 2}, {a: 1, b: 2})) 39 | assert(isEqual({a: 1, b: arr}, {a: 1, b: arr})) 40 | refute(isEqual({a: 1, b: 2}, {a: 1, b: '2'})) 41 | refute(isEqual({a: 1, b: 2}, {a: 1})) 42 | refute(isEqual({a: 1, b: {c: 3}}, {a: 1, b: {c: 3}})) 43 | }) 44 | 45 | test('extend', () => { 46 | assert.equals(extend({}, {a: 1, b: 2}, null, {c: 3}), {a: 1, b: 2, c: 3}) 47 | 48 | let obj = {d: 4} 49 | let target = {} 50 | extend(target, obj) 51 | target.a = 1 52 | obj.b = 2 53 | assert.equals(obj, {b: 2, d: 4}) 54 | assert.equals(target, {a: 1, d: 4}) 55 | }) 56 | -------------------------------------------------------------------------------- /tests/unit/linksTest.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import { assert } from 'referee' 3 | import { intercept } from '../../lib/links' 4 | 5 | let {suite, test, beforeEach, afterEach} = window 6 | let mouse = window.effroi.mouse 7 | let $container 8 | 9 | suite('links') 10 | 11 | beforeEach(() => { 12 | $container = $('
    ').appendTo('body') 13 | }) 14 | afterEach(() => { 15 | $container.empty().remove() 16 | $(document).off('click') 17 | }) 18 | 19 | test('intercepts link clicks', () => { 20 | let $a = $('foo').appendTo($container) 21 | // prevent navigation 22 | 23 | let calledWith = [] 24 | let cb = (event, el) => calledWith.push({event, el}) 25 | 26 | // proxy all clicks via this callback 27 | let dispose = intercept(cb) 28 | 29 | // install another click handler that will prevent 30 | // the navigation, we must install this after the 31 | // link.intercept has been already called 32 | let navPreventedCount = 0 33 | $(document).on('click', e => { 34 | navPreventedCount++ 35 | e.preventDefault() 36 | }) 37 | 38 | // now test that when clicking the link, the calledWith 39 | mouse.click($a.get(0)) 40 | // it calls back with event and el 41 | assert.equals(calledWith[0].event.target, calledWith[0].el) 42 | // and the el is the link that was clicked 43 | assert.equals(calledWith[0].el, $a.get(0)) 44 | assert.equals(navPreventedCount, 1) 45 | 46 | // test that cleanup works 47 | dispose() 48 | // clicking this time 49 | mouse.click($a.get(0)) 50 | // should not call the cb again 51 | assert.equals(calledWith.length, 1) 52 | // only the nav prevention should kick in 53 | assert.equals(navPreventedCount, 2) 54 | }) 55 | -------------------------------------------------------------------------------- /tests/unit/pathTest.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'referee' 2 | import qs from '../../lib/qs' 3 | import Path from '../../lib/path' 4 | 5 | let {suite, test} = window 6 | 7 | suite('Path') 8 | 9 | test('Path.extractParamNames', () => { 10 | assert.equals(Path.extractParamNames('a/b/c'), []) 11 | assert.equals(Path.extractParamNames('/comments/:a/:b/edit'), ['a', 'b']) 12 | assert.equals(Path.extractParamNames('/files/:path*.jpg'), ['path']) 13 | }) 14 | 15 | test('Path.extractParams', () => { 16 | assert.equals(Path.extractParams('a/b/c', 'a/b/c'), {}) 17 | assert.equals(Path.extractParams('a/b/c', 'd/e/f'), null) 18 | 19 | assert.equals(Path.extractParams('comments/:id.:ext/edit', 'comments/abc.js/edit'), { id: 'abc', ext: 'js' }) 20 | 21 | assert.equals(Path.extractParams('comments/:id?/edit', 'comments/123/edit'), { id: '123' }) 22 | assert.equals(Path.extractParams('comments/:id?/edit', 'comments/the%2Fid/edit'), { id: 'the/id' }) 23 | assert.equals(Path.extractParams('comments/:id?/edit', 'comments//edit'), null) 24 | assert.equals(Path.extractParams('comments/:id?/edit', 'users/123'), null) 25 | 26 | assert.equals(Path.extractParams('one, two', 'one, two'), {}) 27 | assert.equals(Path.extractParams('one, two', 'one two'), null) 28 | 29 | assert.equals(Path.extractParams('/comments/:id/edit now', '/comments/abc/edit now'), { id: 'abc' }) 30 | assert.equals(Path.extractParams('/comments/:id/edit now', '/users/123'), null) 31 | 32 | assert.equals(Path.extractParams('/files/:path*', '/files/my/photo.jpg'), { path: 'my/photo.jpg' }) 33 | assert.equals(Path.extractParams('/files/:path*', '/files/my/photo.jpg.zip'), { path: 'my/photo.jpg.zip' }) 34 | assert.equals(Path.extractParams('/files/:path*.jpg', '/files/my%2Fphoto.jpg'), { path: 'my/photo' }) 35 | assert.equals(Path.extractParams('/files/:path*', '/files'), { path: undefined }) 36 | assert.equals(Path.extractParams('/files/:path*', '/files/'), { path: undefined }) 37 | assert.equals(Path.extractParams('/files/:path*.jpg', '/files/my/photo.png'), null) 38 | 39 | // splat with named 40 | assert.equals(Path.extractParams('/files/:path*.:ext', '/files/my/photo.jpg'), { path: 'my/photo', ext: 'jpg' }) 41 | 42 | // multiple splats 43 | assert.equals(Path.extractParams('/files/:path*.:ext*', '/files/my/photo.jpg/gif'), { path: 'my/photo', ext: 'jpg/gif' }) 44 | 45 | // one more more segments 46 | assert.equals(Path.extractParams('/files/:path+', '/files/my/photo.jpg'), { path: 'my/photo.jpg' }) 47 | assert.equals(Path.extractParams('/files/:path+', '/files/my/photo.jpg.zip'), { path: 'my/photo.jpg.zip' }) 48 | assert.equals(Path.extractParams('/files/:path+.jpg', '/files/my/photo.jpg'), { path: 'my/photo' }) 49 | assert.equals(Path.extractParams('/files/:path+', '/files'), null) 50 | assert.equals(Path.extractParams('/files/:path+', '/files/'), null) 51 | assert.equals(Path.extractParams('/files/:path+.jpg', '/files/my/photo.png'), null) 52 | 53 | assert.equals(Path.extractParams('/archive/:name?', '/archive'), { name: undefined }) 54 | assert.equals(Path.extractParams('/archive/:name?', '/archive/'), { name: undefined }) 55 | assert.equals(Path.extractParams('/archive/:name?', '/archive/foo'), { name: 'foo' }) 56 | assert.equals(Path.extractParams('/archive/:name?', '/archivefoo'), null) 57 | assert.equals(Path.extractParams('/archive/:name?', '/archiv'), null) 58 | 59 | assert.equals(Path.extractParams('/:query/with/:domain', '/foo/with/foo.app'), { query: 'foo', domain: 'foo.app' }) 60 | assert.equals(Path.extractParams('/:query/with/:domain', '/foo.ap/with/foo'), { query: 'foo.ap', domain: 'foo' }) 61 | assert.equals(Path.extractParams('/:query/with/:domain', '/foo.ap/with/foo.app'), { query: 'foo.ap', domain: 'foo.app' }) 62 | assert.equals(Path.extractParams('/:query/with/:domain', '/foo.ap'), null) 63 | 64 | // advanced use case of making params in the middle of the url optional 65 | assert.equals(Path.extractParams('/comments/:id(.*\/?edit)', '/comments/123/edit'), {id: '123/edit'}) 66 | assert.equals(Path.extractParams('/comments/:id(.*\/?edit)', '/comments/edit'), {id: 'edit'}) 67 | assert.equals(Path.extractParams('/comments/:id(.*\/?edit)', '/comments/editor'), null) 68 | assert.equals(Path.extractParams('/comments/:id(.*\/?edit)', '/comments/123'), null) 69 | }) 70 | 71 | test('Path.injectParams', () => { 72 | assert.equals(Path.injectParams('/a/b/c', {}), '/a/b/c') 73 | 74 | assert.exception(() => Path.injectParams('comments/:id/edit', {})) 75 | 76 | assert.equals(Path.injectParams('comments/:id?/edit', { id: '123' }), 'comments/123/edit') 77 | assert.equals(Path.injectParams('comments/:id?/edit', {}), 'comments//edit') 78 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 'abc' }), 'comments/abc/edit') 79 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 0 }), 'comments/0/edit') 80 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 'one, two' }), 'comments/one%2C%20two/edit') 81 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 'the/id' }), 'comments/the%2Fid/edit') 82 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 'alt.black.helicopter' }), 'comments/alt.black.helicopter/edit') 83 | 84 | assert.equals(Path.injectParams('/a/:foo*/d', { foo: 'b/c' }), '/a/b/c/d') 85 | assert.equals(Path.injectParams('/a/:foo*/c/:bar*', { foo: 'b', bar: 'd' }), '/a/b/c/d') 86 | assert.equals(Path.injectParams('/a/:foo*/c/:bar*', { foo: 'b' }), '/a/b/c/') 87 | 88 | assert.equals(Path.injectParams('/a/:foo+/d', { foo: 'b/c' }), '/a/b/c/d') 89 | assert.equals(Path.injectParams('/a/:foo+/c/:bar+', { foo: 'b?', bar: 'd ' }), '/a/b%3F/c/d%20') 90 | assert.exception(() => Path.injectParams('/a/:foo+/c/:bar+', { foo: 'b' })) 91 | 92 | assert.equals(Path.injectParams('/foo.bar.baz'), '/foo.bar.baz') 93 | }) 94 | 95 | test('Path.extractQuery', () => { 96 | assert.equals(Path.extractQuery(qs, '/?id=def&show=true'), { id: 'def', show: 'true' }) 97 | assert.equals(Path.extractQuery(qs, '/?id=a%26b'), { id: 'a&b' }) 98 | assert.equals(Path.extractQuery(qs, '/a/b/c'), null) 99 | }) 100 | 101 | test('Path.withoutQuery', () => { 102 | assert.equals(Path.withoutQuery('/a/b/c?id=def'), '/a/b/c') 103 | }) 104 | 105 | test('Path.withQuery', () => { 106 | assert.equals(Path.withQuery(qs, '/a/b/c', { id: 'def' }), '/a/b/c?id=def') 107 | assert.equals(Path.withQuery(qs, '/a/b/c', { id: 'def', foo: 'bar', baz: undefined }), '/a/b/c?id=def&foo=bar') 108 | assert.equals(Path.withQuery(qs, '/path?a=b', { c: 'f&a=i#j+k' }), '/path?c=f%26a%3Di%23j%2Bk') 109 | }) 110 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | context: __dirname, 3 | output: { 4 | library: 'cherrytree', 5 | libraryTarget: 'umd' 6 | }, 7 | resolve: { 8 | alias: { 9 | 'cherrytree': __dirname, 10 | 'expect': 'referee/lib/expect' 11 | } 12 | }, 13 | module: { 14 | loaders: [ 15 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel', query: {presets: ['es2015']} } 16 | ] 17 | }, 18 | devtool: process.env.DEBUG ? 'inline-source-map' : false 19 | } 20 | --------------------------------------------------------------------------------