Home Manual Reference Source Repository

backbone-es6/src/Router.js

'use strict';

import _             from 'underscore';
import BackboneProxy from './BackboneProxy.js';
import Events        from 'typhonjs-core-backbone-events/src/Events.js';

// Private / internal methods ---------------------------------------------------------------------------------------

/**
 * Cached regular expressions for matching named param parts and splatted parts of route strings.
 * @type {RegExp}
 */
const s_ESCAPE_REGEX = /[\-{}\[\]+?.,\\\^$|#\s]/g;
const s_NAMED_PARAM = /(\(\?)?:\w+/g;
const s_OPTIONAL_PARAM = /\((.*?)\)/g;
const s_SPLAT_PARAM = /\*\w+/g;

/**
 * Bind all defined routes to `Backbone.history`. We have to reverse the order of the routes here to support behavior
 * where the most general routes can be defined at the bottom of the route map.
 *
 * @param {Router}   router   - Instance of `Backbone.Router`.
 */
const s_BIND_ROUTES = (router) =>
{
   if (!router.routes) { return; }

   router.routes = _.result(router, 'routes');

   _.each(_.keys(router.routes), (route) =>
   {
      router.route(route, router.routes[route]);
   });
};

/**
 * Given a route, and a URL fragment that it matches, return the array of extracted decoded parameters. Empty or
 * unmatched parameters will be treated as `null` to normalize cross-browser behavior.
 *
 * @param {string}   route - A route string or regex.
 * @param {string}   fragment - URL fragment.
 * @returns {*}
 */
const s_EXTRACT_PARAMETERS = (route, fragment) =>
{
   const params = route.exec(fragment).slice(1);

   return _.map(params, (param, i) =>
   {
      // Don't decode the search params.
      if (i === params.length - 1) { return param || null; }
      return param ? decodeURIComponent(param) : null;
   });
};

/**
 * Convert a route string into a regular expression, suitable for matching against the current location hash.
 *
 * @param {string}   route - A route string or regex.
 * @returns {RegExp}
 */
const s_ROUTE_TO_REGEX = (route) =>
{
   route = route.replace(s_ESCAPE_REGEX, '\\$&')
    .replace(s_OPTIONAL_PARAM, '(?:$1)?')
    .replace(s_NAMED_PARAM, (match, optional) =>
    {
       return optional ? match : '([^/?]+)';
    })
    .replace(s_SPLAT_PARAM, '([^?]*?)');
   return new RegExp(`^${route}(?:\\?([\\s\\S]*))?$`);
};

/**
 * Backbone.Router - Provides methods for routing client-side pages, and connecting them to actions and events.
 * (http://backbonejs.org/#Router)
 * ---------------
 * Web applications often provide linkable, bookmarkable, shareable URLs for important locations in the app. Until
 * recently, hash fragments (#page) were used to provide these permalinks, but with the arrival of the History API,
 * it's now possible to use standard URLs (/page). Backbone.Router provides methods for routing client-side pages, and
 * connecting them to actions and events. For browsers which don't yet support the History API, the Router handles
 * graceful fallback and transparent translation to the fragment version of the URL.
 *
 * During page load, after your application has finished creating all of its routers, be sure to call
 * Backbone.history.start() or Backbone.history.start({pushState: true}) to route the initial URL.
 *
 * routes - router.routes
 * The routes hash maps URLs with parameters to functions on your router (or just direct function definitions, if you
 * prefer), similar to the View's events hash. Routes can contain parameter parts, :param, which match a single URL
 * component between slashes; and splat parts *splat, which can match any number of URL components. Part of a route can
 * be made optional by surrounding it in parentheses (/:optional).
 *
 * For example, a route of "search/:query/p:page" will match a fragment of #search/obama/p2, passing "obama" and "2" to
 * the action.
 *
 * A route of "file/*path" will match #file/nested/folder/file.txt, passing "nested/folder/file.txt" to the action.
 *
 * A route of "docs/:section(/:subsection)" will match #docs/faq and #docs/faq/installing, passing "faq" to the action
 * in the first case, and passing "faq" and "installing" to the action in the second.
 *
 * Trailing slashes are treated as part of the URL, and (correctly) treated as a unique route when accessed. docs and
 * docs/ will fire different callbacks. If you can't avoid generating both types of URLs, you can define a "docs(/)"
 * matcher to capture both cases.
 *
 * When the visitor presses the back button, or enters a URL, and a particular route is matched, the name of the action
 * will be fired as an event, so that other objects can listen to the router, and be notified. In the following example,
 * visiting #help/uploading will fire a route:help event from the router.
 *
 * @example
 * routes: {
 *    "help/:page":         "help",
 *    "download/*path":     "download",
 *    "folder/:name":       "openFolder",
 *    "folder/:name-:mode": "openFolder"
 * }
 *
 * router.on("route:help", function(page) {
 *    ...
 * });
 *
 * @example
 * Old extend - Backbone.Router.extend(properties, [classProperties])
 * Get started by creating a custom router class. Define actions that are triggered when certain URL fragments are
 * matched, and provide a routes hash that pairs routes to actions. Note that you'll want to avoid using a leading
 * slash in your route definitions:
 *
 * var Workspace = Backbone.Router.extend({
 *    routes: {
 *       "help":                 "help",    // #help
 *       "search/:query":        "search",  // #search/kiwis
 *       "search/:query/p:page": "search"   // #search/kiwis/p7
 *    },
 *
 *    help: function() {
 *       ...
 *    },
 *
 *    search: function(query, page) {
 *       ...
 *    }
 * });
 *
 * @example
 * Converting the above example to ES6 using a getter method for `routes`:
 * class Workspace extends Backbone.Router {
 *    get routes() {
 *       return {
 *          "help":                 "help",    // #help
 *          "search/:query":        "search",  // #search/kiwis
 *          "search/:query/p:page": "search"   // #search/kiwis/p7
 *       };
 *    }
 *
 *    help() {
 *       ...
 *    },
 *
 *    search(query, page) {
 *       ...
 *    }
 * }
 *
 * @example
 * Basic default "no route router":
 * new Backbone.Router({ routes: { '*actions': 'defaultRoute' } });
 */
export default class Router extends Events
{
   /**
    * When creating a new router, you may pass its routes hash directly as an option, if you choose. All options will
    * also be passed to your initialize function, if defined.
    *
    * @see http://backbonejs.org/#Router-constructor
    *
    * @param {object}   options  - Optional parameters which may contain a "routes" object literal.
    */
   constructor(options = {})
   {
      super();

      // Must detect if there are any getters defined in order to skip setting this value.
      const hasRoutesGetter = !_.isUndefined(this.routes);

      if (!hasRoutesGetter && options.routes)
      {
         /**
          * Stores the routes hash.
          * @type {object}
          */
         this.routes = options.routes;
      }

      s_BIND_ROUTES(this);

      this.initialize(...arguments);
   }

   /* eslint-disable no-unused-vars */
   /**
    * Execute a route handler with the provided parameters.  This is an excellent place to do pre-route setup or
    * post-route cleanup.
    *
    * @see http://backbonejs.org/#Router-execute
    *
    * @param {function} callback - Callback function to execute.
    * @param {*[]}      args     - Arguments to apply to callback.
    * @param {string}   name     - Named route.
    */
   execute(callback, args, name)
   {
      /* eslint-enable no-unused-vars */
      if (callback) { callback.apply(this, args); }
   }

   /**
    * Initialize is an empty function by default. Override it with your own initialization logic.
    *
    * @see http://backbonejs.org/#Router-constructor
    * @abstract
    */
   initialize()
   {
   }

   /**
    * Simple proxy to `Backbone.history` to save a fragment into the history.
    *
    * @see http://backbonejs.org/#Router-navigate
    * @see History
    *
    * @param {string}   fragment - String representing an URL fragment.
    * @param {object}   options - Optional hash containing parameters for navigate.
    * @returns {Router}
    */
   navigate(fragment, options)
   {
      BackboneProxy.backbone.history.navigate(fragment, options);
      return this;
   }

   /**
    * Manually bind a single named route to a callback. For example:
    *
    * @example
    * this.route('search/:query/p:num', 'search', function(query, num)
    * {
    *    ...
    * });
    *
    * @see http://backbonejs.org/#Router-route
    *
    * @param {string|RegExp}  route    -  A route string or regex.
    * @param {string}         name     -  A name for the route.
    * @param {function}       callback -  A function to invoke when the route is matched.
    * @returns {Router}
    */
   route(route, name, callback)
   {
      if (!_.isRegExp(route)) { route = s_ROUTE_TO_REGEX(route); }
      if (_.isFunction(name))
      {
         callback = name;
         name = '';
      }
      if (!callback) { callback = this[name]; }

      BackboneProxy.backbone.history.route(route, (fragment) =>
      {
         const args = s_EXTRACT_PARAMETERS(route, fragment);

         if (this.execute(callback, args, name) !== false)
         {
            this.trigger(...([`route:${name}`].concat(args)));
            this.trigger('route', name, args);
            BackboneProxy.backbone.history.trigger('route', this, name, args);
         }
      });

      return this;
   }
}