Source js/bauplan.router.base.js

define([
        "lodash",
        "jquery",
        "backbone",
        "bauplan.authentication",
        "bauplan.tracker"
    ], function (_, jQuery, Backbone, BauplanAuthentication, Tracker) {
/**
 * @module bauplan%router%base
 * @description ## RouterBase
 *
 *     var BauplanRouterBase = require("bauplan.router.base");
 *
 * or as part of the Bauplan bundle
 *
 *     var Bauplan = require("bauplan");
 *     var BauplanRouterBase = Bauplan.BauplanRouterBase;
 *
 * To create and instantiate a new Router instance
 *
 *     var Router = RouterBase.extend();
 *     var router = new Router({
 *         config: {
 *             "foo/:param": "foo"
 *         },
 *         methods: {
 *             "foo": function (param) {
 *                 …
 *             }
 *         }
 *     });
 *
 * This creates a route with a URL pattern
 * 
 * - foo - /^\/foo\/([^\/]+)$/ eg. /foo/lish
 * 
 * When called invokes the foo method with the argument param (lish)
 *
 * The individual config properties can also be passed as objects 
 * 
 *     var router = new Router({
 *         config: {
 *             "bar" : {
 *                 name: "barbar",
 *                 authenticated: true
 *             },
 *             "baz/:a/:b/*c": {
 *                 name: "baz",
 *                 method: function (a, b, c) {}
 *             }
 *         },
 *         methods: {
 *             "barbar": function () {}
 *         }
 *     });
 *
 * This creates 2 routes with the following URL patterns
 * 
 * 1. barbar - /^\/bar$/ ie. /bar
 * 2. baz - /^\/baz\/([^\/]+)\/([^\/]+)\/(.*)$/ eg. /baz/tas/tic/al/journey
 *
 * The barbar route requires authentication and invokes the barbar method
 *
 * The baz route invokes the method defined in the method property of config.baz, with the arguments a (tas), b (tic) and c (al/journey)
 *
 * 
 * ### Link handling
 *
 * All a element clicks are intercepted
 * unless:
 * - _target
 * - .external
 * - mailto:
 *
 * See {@link module:bauplan%router%base~window:on:click:a}
 * 
 * @return {constructor} BauplanRouterBase
 */
    // alias
    var windoc = window.document;

    /**
     * @method jQuery%rootCookie
     * @description Set cookie operation at the root path
     * @param  {string} name Cookie name
     * @param  {string|object} value Cookie value
     * @param  {object} [options] Options to pass to jQuery.cookie
     * @return Cookie value
     */
    jQuery.rootCookie = function(name, value, options) {
        options = options || {};
        options.path = "/";
        return jQuery.cookie(name, value, options);
    };

    /**
     * @method jQuery%removeRootCookie
     * @description Delete cookie at the root path
     * @param  {string} name Cookie name
     * @param  {object} [options] Options to pass to jQuery.cookie
     */
    jQuery.removeRootCookie = function(name, options) {
        options = options || {};
        options.path = "/";
        return jQuery.removeCookie(name, options);
    };

    jQuery.fn.outerHtml = function(){
        // IE, Chrome & Safari will comply with the non-standard outerHTML, all others (FF) will have a fall-back for cloning
        return (!this.length) ? this : (this[0].outerHTML || (
          function(el){
              var div = document.createElement("div");
              div.appendChild(el.cloneNode(true));
              var contents = div.innerHTML;
              div = null;
              return contents;
        })(this[0]));
    };

    // Alternatively, checkout http://madapaja.github.io/jquery.selection/
    jQuery.fn.getSelection = function() {
        var e = (this.jquery) ? this[0] : this;
        return (
            /* mozilla / dom 3.0 */
            ('selectionStart' in e && function() {
                var l = e.selectionEnd - e.selectionStart;
                return { start: e.selectionStart, end: e.selectionEnd, length: l, text: e.value.substr(e.selectionStart, l) };
            }) ||
            /* exploder */
            (document.selection && function() {
                e.focus();
                var r = document.selection.createRange();
                if (r === null) {
                    return { start: 0, end: e.value.length, length: 0 };
                }
                var re = e.createTextRange();
                var rc = re.duplicate();
                re.moveToBookmark(r.getBookmark());
                rc.setEndPoint('EndToStart', re);

                return { start: rc.text.length, end: rc.text.length + r.text.length, length: r.text.length, text: r.text };
            }) ||
            /* browser not supported */
            function() { return null; }
        )();
    };
    jQuery.fn.setSelection = function(start, end) {
        if (!end) {
            end = start;
        }
        return this.each(function() {
            if (this.setSelectionRange) {
                this.focus();
                this.setSelectionRange(start, end);
            } else if (this.createTextRange) {
                var range = this.createTextRange();
                range.collapse(true);
                range.moveEnd('character', end);
                range.moveStart('character', start);
                range.select();
            }
        });
    };

    /**
     * @method reverse
     * @static
     * @param {string} name Route name
     * @param {object} params Additional params for route
     * @description Get URL for a route
     * @return {string} URL for route
     */
    Backbone.Router.prototype.reverse = function (name, params) {
        var urlStub = this.reverseRoutes[name];
        var aliasMatch = urlStub.match(/^alias:(.*)/);
        if (aliasMatch) {
            urlStub = this.reverseRoutes[aliasMatch[1]];
        }
        if (urlStub === undefined) {
            //console.log("WTF - no match for reverse route " + name);
            return "";
        }
        params = params || {};
        for (var param in params) {
            var paramRegex = new RegExp("/[:\\*]"+ param);
            urlStub = urlStub.replace(paramRegex, "/" + params[param]);
        }
        urlStub = "/" + urlStub;
        // why not check value of params[param] and remove there and then?
        urlStub = urlStub.replace(/\/undefined.*/, "");
        //remove unused params
        urlStub = urlStub.replace(/\/[:\*].*/, "");

        return urlStub;
    };

    /**
     * @method reverseWithAuth
     * @static
     * @param {string} name Route name
     * @param {object} params Additional params for route
     * @description Get protected URL for a route
     * @return {string} URL for route
     */
    Backbone.Router.prototype.reverseWithAuth = function(name, params) {
        var routeStub = this.reverse(name, params).replace(/^\//, "");
        var authroute = this.getAuthRoute();
        return this.reverse(authroute, {route: routeStub});
    };

    var requiresLogin = {};

    /**
     * @member {array} urlList
     * @private
     * @description Simple array of URLs visited 
     */
    var urlList = [];

    var BauplanRouterBase = Backbone.Router.extend({

        /**
         * @static
         * @param {object} options
         * @param {object} options.config Key/value pairs of route URL patterns and route options (name, method, authentication)
         * @description Create router instance
         *
         * - Register routes
         * - Register corresponding methods
         * - Register routes requiring authentication
         * - Attach event handlers to allow non-app links to work as normal
         * - Start Backbone.History
         * - Perform stored redirect if needed
         * - Check whether current page requires authentication
         * - Track pageview
         */
        initialize: function (options) {
            this.bauplan.Router = this;
            if (!options) {
                options = {};
            }
            var routes = [];
            for (var routeKey in options.config) {
                routes.unshift(routeKey);
            }
            for (var i = 0, routesLen = routes.length; i < routesLen; i++) {
                var route = routes[i];
                var routeVal = options.config[route];
                if (typeof routeVal === "string") {
                    routeVal = {
                        name: routeVal
                    };
                }
                if (!routeVal.method) {
                    routeVal.method = options.methods[routeVal.name];
                }
                this.route(route, routeVal.name, routeVal.method);
                // TODO: good. but let's add url matching too
                if (routeVal.authentication) {
                    requiresLogin[routeVal.name] = true;
                }
            }
            /*this.on("all", function(route) {
                //alert("all got called");
                //console.log("all", route);
                this.current = route;
            });*/
            var that = this;
            /**
             * @event window:on:click:a
             * @description Handle all triggerings of link elements
             *
             * Allow normal link behaviour if
             *
             * - Link has a target (and not siteinfo)
             * - Link is an email (mailto:)
             * - Link has a class of "external"
             *     - if there is an external method, use that
             *     - otherwise, allow link to load
             * - Link is a fragment identifier (#)
             */
            jQuery(document).on("click", "a", function (e) {
                var a = e.target;
                if (a.tagName.toLowerCase() !== "a") {
                    a = jQuery(a).closest("a");
                }
                if (jQuery(a).attr("target") && jQuery(a).attr("target") !== "siteinfo") {
                    return;
                }
                var url = jQuery(a).attr("href"); //.replace(/^\//, "");
                if (url.match(/^mailto:/)) {
                    return;
                }
                if (jQuery(a).hasClass("external")) {
                    if (that.external) {
                        that.external(url);
                        e.preventDefault();
                        e.stopPropagation();
                    }
                    return;
                }
                // || jQuery(a).hasClass("authentication")
                if (url && url.indexOf("#") === 0) {
                    return;
                }
                that.navigate(url, {trigger: true}); //, replace: true,
                e.preventDefault();
                e.stopPropagation();
            });
            Backbone.history.start({ pushState: true });
            this.urlStack.current = windoc.location.pathname;
            //this.bind("all", this.allRoutes);
            if (this.storedRedirect()) {
                return;
            }
            var curl = document.location.pathname;
            var authdurl = this.checkAuthenticatedUrl(curl, true);
            if (authdurl) {
                return;
            }
            Tracker.pageview(curl);
        },
        /**
         * @static
         * @param {string} route Route name
         * @param {object} [options] Options to pass to this.navigate
         * @param {object} [options.trigger=true] Route triggering
         * @param {object} [options.options] Overriding options
         * @param {object} [options.params] Params to pass to route URL
         * @description Navigate to a route by the name of the route, rather than by URL
         *
         */
        callRoute: function (route, options) {
            options = options || {};
            var params = options;
            if (options.options) {
                options = _.extend({}, options.options);
                delete params.options;
            }
            if (params.params) {
                params = _.extend({}, params.params);
                delete options.params;
            }

            if (options.trigger === undefined) {
                options.trigger = true;
            }
            var url = this.reverse(route, params);
            this.navigate(url, options);
        },
        /**
         * @static
         * @param {string} route Route name
         * @param {object} [options] this.navigate options
         * @description Interrupt any currently invoked route and navigate to the route specified
         */
        override: function (route, options) {
            var r = this;
            setTimeout(function(){
                r.callRoute(route, options);
            }, 1);
        },
        /**
         * @static
         * @description Get a redirect route based on whether the user has registered or not
         * @return {string} Authentication redirect route
         */
        getAuthRoute: function () {
            var authroute = BauplanAuthentication.get("registered") ? "login" : "register";
            authroute += "-redirect";
            return authroute;
        },
        /**
         * @static
         * @param {string} url URL which requires authentication
         * @param {boolean} [replace] Whether to replace the previous entry in the user agent’s history
         * @description  Navigate to a route where the user can authenticate before proceeding to the desired URL
         */
        callRouteWithAuth: function (url, replace) {
            this.callRoute(this.getAuthRoute(), {
                params: {
                    route: url
                },
                options: {
                    trigger: true,
                    replace: replace
                }
            });
        },
        /**
         * @static
         * @param {string} url URL to look up
         * @description Get the route that a URL resolves to
         * @return {string} Route name
         */
        getRoute: function (url) {
            url = url.replace(/^\//, "");
            var BHH = Backbone.history.handlers;
            for (var b = 0, BHHLen = BHH.length; b < BHHLen; b++) {
                handler = BHH[b];
                if (handler.route.test(url)) {
                    return handler.name;
                }
            }
        },
        /**
         * @static
         * @param {string} url URL to check
         * @param {boolean} [replace] Whether to replace the previous entry in the user agent’s history
         * @description  Checks whether a URL needs authentication
         *
         * If it does, and the user is not authenticated, performs a redirect to an authentication page.
         *
         * Once the user has authenticated, they will be redirected to the origally requested URL
         */
        checkAuthenticatedUrl: function (url, replace) {
            url = url.replace(/^\//, "");
            var gatekeeper = requiresLogin[this.getRoute(url)];
            if (gatekeeper && BauplanAuthentication && !BauplanAuthentication.get("loggedIn")) {
                this.callRouteWithAuth(url, replace);
                return true;
            }
            return false;
        },
        /**
         * @static
         * @access private
         * @description  For storing app's URL previous and current history
         */
        urlStack: {},
        /**
         * @static
         * @return {array} List of app URLs in order visited
         */
        getUrlList: function() {
            return urlList;
        },
        /**
         * @static
         * @param {string|object} name Name of previous route
         * @param {object} options XXXX
         * @param {boolean} [options.trigger=true] Whether to trigger the route
         * @param {boolean} [options.replace=true] Whether to replace the URL in the history
         * @param {boolean} [options.route] Route to use for URL
         * @param {boolean} [options.params] Params to pass to Route reversing
         * @param {boolean} [options.url] Explicit URL to use
         * @description Navigate to the app’s idea of the previous route
         */
        previous: function (name, options) {
            if (typeof name === "object") {
                options = name;
            }
            options = options || {
                trigger: true,
                replace: true
            };
            var url;
            if (name) {
                url = this.previousStack[name];
            } else {
                url = this.urlStack.previous;
            }
            if (!url && options.route) {
                url = this.reverse(options.route, options.params);
            }
            if (!url && options.url) {
                url = options.url;
            }
            url = url || "/";
            this.navigate(url, options);
        },
        /**
         * @static
         * @access private
         * @description  Registry for storing app’s previous state
         */
        previousStack : {},
        /**
         * @static
         * @param {string} name Route name
         * @param {boolean} [ignore] Ignore this route
         * @description Update previous route stack
         */
        setPrevious: function(name, ignore) {
            this.previousStack[name] = this.urlStack.previous;
        },
        /**
         * @static
         * @description Empty method to be overriden if stored redirect functionality is desired
         */
        storedRedirect: function() {},
        /**
         * @static
         * @override
         * @param {string} url URL to invoke
         * @param {object} options XXXX
         * @description Overrides Backbone.Router.prototype.navigate
         *
         * - checks whether route is protected and if so, whether the user has the right permissions
         * - redirects to any stored redirect URL
         * - redirects to locked page if needed
         * - updates previous and current url stacks
         * - tracks pageview
         * - sets referrer if needed
         * - prevents invoking empty URLs
         */
        navigate: function(url, options) {
            if (BauplanAuthentication.locked()) {
                // better have alocked route then, eh?
                url = this.reverse("locked");
            }
            // console.log("navigate", url); // enable for debug?
            if (this.storedRedirect.apply(this, arguments)) {
                return;
            }
            if (!url) {
                //console.log("uh oh root");
                return;
            }
            if (!this.checkAuthenticatedUrl(url)) {
                this.urlStack.previous = this.urlStack.current;
                this.urlStack.current = url; // arguments[0]
                if (options.track !== false) {
                    Tracker.pageview(url);
                }
                if (options.referrer !== false) {
                    this.referrer = this.urlStack.previous || windoc.referrer;
                }
                Backbone.Router.prototype.navigate.apply(this, arguments);
            }
        },
        /**
         * @static
         * @override
         * @param {string} route URL pattern
         * @param {string} name Name of route
         * @param {function} callback Callback function for Backbone.Router.prototype.route
         * @description Register a route and update Backbone history
         *
         * Overrides Backbone.Router.prototype.route
         */
        route: function (route, name, callback) {
            this.reverseRoute(route, name);
            Backbone.Router.prototype.route.call(this, route, name, callback);
            // God, this is awful, but surely better than copying and pasting Backbone.Router.prototype.route
            var BHH = Backbone.history.handlers;
            BHH[0].name = name;
        },
        /**
         * @static
         * @private
         * @param {string} route URL pattern
         * @param {string} name Name of route
         * @description  Add route URL pattern to reversed route registry
         */
        reverseRoute: function (route, name) {
            //this.reverseRoutes = this.reverseRoutes || {};
            this.reverseRoutes[name] = route;
        },
        /**
         * @static
         * @description  Registry for reversed routes
         */
        reverseRoutes: {}

    });

    // Along with the awful copy and paste, maybe it's time for Bauplan.History
    /**
     * @override
     * @static
     * @param {arguments} arguments
     * @description  Patch Backbone.History.prototype.loadUrl to enable maintaining urlList
     */
    var loadUrl = Backbone.History.prototype.loadUrl;
    Backbone.History.prototype.loadUrl = function () {
        var matched = loadUrl.apply(this, arguments);
        //Tracker.pageview(this.fragment);
        urlList.push("/" + this.fragment);
        return matched;
    };

    return BauplanRouterBase;
});