/**
* @module Builtins
*/
var RouteRecognizer = require('route-recognizer');
var parseUri = require('parseuri');
/**
* # Routing for Mimeo
*
* This builtin handles routing by managing the browsers history and matching
* routes with injectables (usually components.)
*
* The general workflow would be to inject `$routing` into a
* {{#crossLink "Module/run:method"}}`.run()`{{/crossLink}} injectable on your
* root module along with the injectables you want to match to the routes, and
* {{#crossLink "$routing/set:method"}}define routes there{{/crossLink}}:
*
* mimeo.module('example', [])
* .run([
* '$routing',
* 'usersComponent',
* 'loginComponent',
* ($routing) => {
* $routing.set('/users', usersComponent);
* $routing.set('/login', loginComponent);
* }
* );
*
* ## Generating output
*
* How output is generated is up to the matched injectable. Once an injectable
* is matched to a route, it is invoked with three parameters:
*
* - context
* - renderer
* - targetDOMNode
*
* Context is an object that contains information about the matched route. See
* {{#crossLink "$routing/set:method"}}the `set` method for more details
* {{/crossLink}}. Renderer is a helper to produce output and can be
* configured.
* targetDOMNode is the DOM node that was associated with the route.
*
* Since the injectable has access to the DOM node, it can simply update the
* nodes content to produce output. The `renderer` is not strictly necessary.
* However, when using a rendering library like React, manually calling
* ReactDOM.render(exampleComponent, targetDOMNode) is annoying and also makes
* it impossible to switch to e.g.
* ReactDOMServer.renderToStaticMarkup(exampleComponent) to produce output
* in NodeJS.
*
* Using a renderer has the advantage of being able to change the rendering
* method depending on the environment the app is in. Using
* {{#crossLink
* "$routing/setMakeRenderer:method"}}`setMakeRenderer`{{/crossLink}}
* to define a default renderer allows the matched injectable to simply call
* `renderer(exampleComponent)` and not deal with the specifics of generating
* output. An example for React:
*
* mimeo.module('example', [])
* // target is not used since the custom renderer will take care of
* // mounting the react node
* .component(['usersComponent', () => ($context, $render) => {
* let Users = React.createClass({}); // example component
*
* return $render(<Users />);
* })
* .run(['$routing', 'usersComponent', ($routing, usersComponent) => {
* $routing.setMakeRenderer(function(targetDOMNode) {
* return function(reactNode) {
* return ReactDOM.render(reactNode, targetDOMNode);
* };
* });
*
* $routing.set('/users', usersComponent);
* });
*
* ## Initiate routing
*
* There are three ways to change the current route:
*
* - {{#crossLink "$routing/goto:method"}}goto{{/crossLink}}
* - a-tag with a href and a 'data-internal' attribute
* - a-tag with a href, a 'data-internal' and 'data-no-history' attribute
*
* `.goto()` is mainly used for server-side rendering. If you set a
* {{#crossLink "$routing/setMakeRenderer:method"}}a renderer{{/crossLink}} that
* supports server-side output, you won't have to change your components to
* generate the output. `.goto()` will return a promise that is full-filled
* with the return value from the component. You can have your server-side
* entry-point attach to that promise and then do with the output what you
* need (e.g. send an email, save to a static .html file, etc.)
*
* The other two are simply a-tags in your html. `$routing` attaches an event
* handler to the document that listens to clicks on a-tags with a
* 'data-internal' attribute. The value from the 'href' attribute is used as the
* route to handle. The 'data-no-history' attribute controls whether a new
* browser-history entry is created. If the attribute is present, no history
* is created.
*
* @class $routing
* @static
*/
function Routing($q, $window) {
var routing = new RouteRecognizer();
var defaultRoute;
var anyRouteHandled = false;
var makeRenderer = function(targetAsDOMNode) {
return function(toRender) {
targetAsDOMNode.innerHTML = toRender;
};
};
var onRoutingHandlers = [];
function preventDefault(event) {
if (event.preventDefault) {
event.preventDefault();
} else {
/*
* Internet explorer support
*/
event.returnValue = false;
}
}
function getAttribute(element, attribute) {
if (element[attribute]) {
return element[attribute];
}
if (element.getAttribute) {
return element.getAttribute(attribute);
}
if (!element.attributes) {
return null;
}
var value = null;
for (var i = 0; i < element.attributes.length; ++i) {
if (element.attributes[i].nodeName === attribute) {
value = element.attributes[i].nodeValue;
}
}
return value;
}
function getAncestorWithAttribute(node, attribute) {
if (!node) {
return null;
}
if (getAttribute(node, attribute) !== null) {
return node;
}
return getAncestorWithAttribute(node.parentNode, attribute);
}
function doDefaultRoute(route) {
$window.history.pushState(null, '', route);
return doRouting(route, false, true);
}
function queryToDict(query) {
var dict = {};
query.split('&').map(function(part) {
return part.split('=').map(decodeURIComponent);
}).forEach(function(part) {
if (dict[part[0]]) {
if (!(Object.prototype.toString.call(dict[part[0]]) == '[object Array]')) {
dict[part[0]] = [dict[part[0]]];
}
dict[part[0]].push(part[1])
} else {
dict[part[0]] = part[1];
}
});
return dict;
}
function doRouting(url, doDefault, isDefault) {
anyRouteHandled = true;
var urlParts = parseUri(url);
var handlers = routing.recognize(urlParts.path);
var handlerExecuted = false;
var defaultRouteExecuted = isDefault || false;
var promises = [];
if (handlers) {
for (var i = 0; i < handlers.length; ++i) {
var $context = {
url: urlParts,
params: handlers[i].params,
query: queryToDict(urlParts.query)
};
promises.push(handlers[i].handler($context));
}
handlerExecuted = true;
} else if ((doDefault !== false) && defaultRoute) {
promises.push(doDefaultRoute(defaultRoute));
}
onRoutingHandlers.forEach(function(handler) {
handler(url, urlParts, handlerExecuted, defaultRouteExecuted);
});
return $q.all(promises);
}
function gotoRoute(route) {
$window.history.pushState(null,
'',
route);
return doRouting(route);
}
function replaceRoute(route) {
$window.history.replaceState(null,
'',
route);
return doRouting(route);
}
$window.onpopstate = function() {
doRouting($window.location.href);
};
$window.onclick = function(event) {
var target = event.target || event.srcElement;
/*
* Related to Safari firing events on text nodes
*/
if (target.nodeType === 3) {
target = target.parentNode;
}
/*
* Other elements might be inside an a-tag which end up as event.target,
* so we need to walk the parent nodes to find the a-tag with the 'src'
* attribute
*/
target = getAncestorWithAttribute(target, 'data-internal');
if (target && getAttribute(target, 'data-internal') !== null) {
preventDefault(event);
if (getAttribute(target, 'data-no-history') !== null) {
replaceRoute(getAttribute(target, 'href'));
} else {
gotoRoute(getAttribute(target, 'href'));
}
}
};
$window.onload = function() {
/*
* If a route is handled before .onload is fired (e.g. by calling
* .goto()), then don't do routing. This prevents a double-load as the
* route has already been handled.
*/
if (!anyRouteHandled) {
doRouting($window.location.href);
}
};
return {
/**
* Add event handlers to be executed whenever a new route is handled,
* via {{#crossLink "$routing/goto:method"}}$routing.goto(){{/crossLink}},
* the window.onpopstate event or a click on a controlled link.
*
* @method onRouting
* @for $routing
* @param {Function} handler The callback to be executed when a new url
* is handled. It receives four parameters:
*
* - url {string} The url handled (regardless if handlers are found)
* - parts {object} Parsed url, same as $context.url that's passed
* to a route handler
* - handlerExecuted {Boolean} Whether a handler was found and
* executed
* - defaultRouteExecuted {Boolean} Whether the url handled was the
* default route
*/
'onRouting': function(handler) {
if (!(handler instanceof Function)) {
throw new Error('$routing onRouting event handlers must be functions');
}
onRoutingHandlers.push(handler);
},
/**
* Set a default route to redirect to when the current route isn't
* matched to anything
*
* @method setDefaultRoute
* @for $routing
* @param {string} newDefaultRoute The default path to route to if the
* current path wasn't matched by any defined route
*/
'setDefaultRoute': function(newDefaultRoute) {
if (!((typeof newDefaultRoute === 'string') || newDefaultRoute instanceof String)) {
throw new Error('The default route must be given as a string, e.g. "/app"');
}
defaultRoute = newDefaultRoute;
},
/**
* Set a custom factory for render functions
*
* Render factories receive the DOM target node for the route and
* produce an executable that can be used to render content (that
* executable is called `renderer`).
*
* A new renderer is created every time a route is matched by passing
* the routes target DOM node to the makeRenderer function.
*
* Renderer functions are passed to the injectable that is matched with
* the route. `setMakeRenderer` sets the factory that creates the
* render functions.
*
* The default makeRenderer factory produces renderer functions that
* simply set innerHTML on the target DOM node:
*
* function(targetAsDOMNode) {
* return function(toRender) {
* targetAsDOMNode.innerHTML = toRender;
* };
* }
*
* The injectable for any given route can use the render method like
* this:
*
* mimeo.module('example', [])
* .component(['component', () => ($context, $renderer) => {
* $renderer('<h1>Headline content</h1>');
* }]);
*
* When using a rendering library, it's often beneficial to set a
* custom
* renderer factory to simplify rendering in the component. E.g. with
* React, custom components are mounted on DOM nodes via
*
* ReactDOM.render(<Component/>, DOMNode);
*
* A custom `setMakeRenderer` for React would create a function that
* accepts a React component and mounts it to the routes target DOM
* node:
*
* $routing.setMakeRenderer(function(targetDOMNode) {
* return function(component) {
* ReactDOM.render(component, targetDOMNode);
* }
* });
*
* @method setMakeRenderer
* @for $routing
* @param {Function} newMakeRenderer - Set the renderer factory. Gets
* the routes target DOM node passed in
*/
'setMakeRenderer': function(newMakeRenderer) {
if (!(newMakeRenderer instanceof Function)) {
throw new Error('The makeRenderer must be a function');
}
makeRenderer = newMakeRenderer;
},
/**
* Sets a handler for a route. There can be multiple handlers for any
* route.
*
* The route matching is handled by (the route-recognizer package,
* read the docs regarding the route syntax
* here)[https://github.com/tildeio/route-recognizer#usage]. You can
* capture parts of the url with `:name` and `*name`:
*
* $routing.set('/users/:id')
* //=> matches /users/1 to { id: 1 }
*
* $routing.set('/about/*path')
* //=> matches /about/location/city to { path: 'location/city' }
*
* Captured segments of the url will be available in `$context.params`.
*
* Setting a route matches an injectable with a url:
*
* $routing.set('/example-url', exampleInjectable);
*
* The injectable that will receive three parameters:
*
* - $context - information about the current route and access to url
* parameters
* - $renderer - the renderer $routing is configured to use. Default
* just set the html content of the target DOM node
* - $target - DOM node that the content should end up in. Useful if
* you don't want to use $renderer for a specific route
*
* Set routes in a `.run()` block on your root module:
*
* mimeo.bootstrap('example', [])
* .component(['users', () => ($context, $renderer) => {
* $renderer('<ul><li>John</li><li>Alice</li</ul>');
* }])
* .component(['loginForm', () => ($context, $renderer) => {
* $renderer('<form></form>');
* }])
* .run([
* '$routing',
* 'users',
* 'loginForm',
* ($routing, users, loginForm) => {
* $routing.set('/users', users);
* $routing.set('/login', loginForm);
* }
* ]);
*
* The `.run()` block needs to have all component-injectables you want
* to set as route handlers injected. `.set()` requires the actual
* injectables to be passed in, not the injectables name.
*
* $context contains information about the current route, it has three
* attributes:
*
* - `$context.params` will contain any matched segments from the url.
* - `$context.query` will contain decoded query parameters as a
* key-value hash. Repeating keys will create an array:
* `/example?a=1&b=2&c=3 //=> { a: [1, 2, 3] }`
* - `$context.url` represents the parsed url as a key-value store.
*
* `$context.url` example for
* `http://localhost:3000/?example-key=value`:
*
* $context.url = {
* anchor: '',
* authority: 'localhost:3000',
* directory: '/',
* file: '',
* host: 'localhost',
* password: '',
* path: '/',
* port: '3000',
* protocol: 'http',
* query: 'example-key=value',
* relative: '/?example-key=value',
* source: 'http://localhost:3000/?example-key=value',
* user: '',
* userInfo: ''
* }
*
* @method set
* @for $routing
* @param {string} route
* @param {string} target
* @param {Function} injectable
* @param {string} [name]
*/
'set': function(route, target, injectable, name) {
if (!(injectable instanceof Function)) {
var message = 'To set a route, you have to provide an injectable that is executable (i.e. instanceof Function). Route: ' + route + ', stringified injectable: "' + String(
injectable + '"');
if ((target instanceof Function) && ((injectable instanceof String) || (typeof injectable === 'string'))) {
message += '. Target is a function and injectable is a string. You might have switched the parameters, please double-check that';
}
throw new Error(message);
}
routing.add([
{
path: route,
handler: function($context) {
var renderReturn;
var targetAsDOMNode = $window.document.getElementById(
target);
var renderer = makeRenderer(targetAsDOMNode);
if (injectable.render) {
renderReturn = injectable.render($context,
renderer,
targetAsDOMNode);
} else {
renderReturn = injectable($context,
renderer,
targetAsDOMNode);
}
return $q.when(renderReturn);
}
}
], {'as': name});
},
/**
* Matches `route` and executes all associated injectables
*
* The return values from the matched injectables are turned into a
* promise using {{#crossLink
* "$q/when:method"}}$q.when(){{/crossLink}},
* and then aggregated with {{#crossLink
* "$q/all:method"}}$q.all(){{/crossLink}} and then returned by
* `goto()`. This allows handling asynchronous requests on the server.
*
* @example
* mimeo.module('example', []).
* .component('Blog', ['$http', ($http) => () => {
* return $http.get('/example-api/blogs')
* .then((response) => {
* return response.data;
* })
* .then((blogPosts) => {
* return //turn blog posts into html
* });
* })
* .run(['$routing', 'Blog', ($routing, Blog) => {
* $routing.set('/blogs', Blog);
* }])
* .run(['$routing', ($routing) => {
* $routing.goto('/blogs').then((blogHtml) => {
* // save to cdn
* });
* });
*
* @method goto
* @for $routing
* @param {string} route Route to go to
* @returns {Promise} Promise that is resolved with the return values
* from all matched routes
*/
'goto': function(route) {
return gotoRoute(route);
}
}
}
module.exports = {
'Routing': Routing
};