From 87fdf6a2607456abed1391985160ba9c971b6323 Mon Sep 17 00:00:00 2001 From: EisenbergEffect Date: Wed, 2 Apr 2014 17:12:43 -0400 Subject: [PATCH] Fleshing out more utility capabilities of the router. --- .editorconfig | 14 +++ .gitignore | 7 ++ .npmignore | 11 ++ src/history.js | 215 +++++++++++++++++++++++++++++++++++++++ src/router.js | 268 +++++++++++++++++++++++++++++++++++++++++++++---- src/util.js | 15 +++ 6 files changed, 509 insertions(+), 21 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 src/history.js create mode 100644 src/util.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..74bf86b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# 2 space indentation +[**.*] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5d8994 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +compiled +dist +sauce_connect.log +.idea +.DS_STORE +temp diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..f8295ff --- /dev/null +++ b/.npmignore @@ -0,0 +1,11 @@ +test/ +*.sublime-project +*.sublime-workspace +gulpfile.js +karma.conf.js +.travis.yml +test-main.js +.npmignore +*.orig +Gruntfile.* +compiled/ diff --git a/src/history.js b/src/history.js new file mode 100644 index 0000000..6558d2e --- /dev/null +++ b/src/history.js @@ -0,0 +1,215 @@ +import extend from './util'; + +// Cached regex for stripping a leading hash/slash and trailing space. +var routeStripper = /^[#\/]|\s+$/g; + +// Cached regex for stripping leading and trailing slashes. +var rootStripper = /^\/+|\/+$/g; + +// Cached regex for detecting MSIE. +var isExplorer = /msie [\w.]+/; + +// Cached regex for removing a trailing slash. +var trailingSlash = /\/$/; + +// Update the hash location, either replacing the current entry, or adding +// a new one to the browser history. +function updateHash(location, fragment, replace) { + if (replace) { + var href = location.href.replace(/(javascript:|#).*$/, ''); + location.replace(href + '#' + fragment); + } else { + // Some browsers require that `hash` contains a leading #. + location.hash = '#' + fragment; + } +}; + +var history = { + interval: 50, + active: false +}; + +// Ensure that `History` can be used outside of the browser. +if (typeof window !== 'undefined') { + history.location = window.location; + history.history = window.history; +} + +history.getHash = function (window) { + var match = (window || history).location.href.match(/#(.*)$/); + return match ? match[1] : ''; +}; + +history.getFragment = function (fragment, forcePushState) { + if (fragment == null) { + if (history._hasPushState || !history._wantsHashChange || forcePushState) { + fragment = history.location.pathname + history.location.search; + var root = history.root.replace(trailingSlash, ''); + if (!fragment.indexOf(root)) { + fragment = fragment.substr(root.length); + } + } else { + fragment = history.getHash(); + } + } + + return fragment.replace(routeStripper, ''); +}; + +history.activate = function (options) { + if (history.active) { + throw new Error("History has already been activated."); + } + + history.active = true; + + // Figure out the initial configuration. Do we need an iframe? + // Is pushState desired ... is it available? + history.options = extend({}, { root: '/' }, history.options, options); + history.root = history.options.root; + history._wantsHashChange = history.options.hashChange !== false; + history._wantsPushState = !!history.options.pushState; + history._hasPushState = !!(history.options.pushState && history.history && history.history.pushState); + + var fragment = history.getFragment(); + + // Normalize root to always include a leading and trailing slash. + history.root = ('/' + history.root + '/').replace(rootStripper, '/'); + + // Depending on whether we're using pushState or hashes, and whether + // 'onhashchange' is supported, determine how we check the URL state. + if (history._hasPushState) { + window.onpopstate = history.checkUrl; + } else if (history._wantsHashChange && ('onhashchange' in window)) { + window.addEventListener('hashchange', history.checkUrl); + } else if (history._wantsHashChange) { + history._checkUrlInterval = setInterval(history.checkUrl, history.interval); + } + + // Determine if we need to change the base url, for a pushState link + // opened by a non-pushState browser. + history.fragment = fragment; + var loc = history.location; + var atRoot = loc.pathname.replace(/[^\/]$/, '$&/') === history.root; + + // Transition from hashChange to pushState or vice versa if both are requested. + if (history._wantsHashChange && history._wantsPushState) { + // If we've started off with a route from a `pushState`-enabled + // browser, but we're currently in a browser that doesn't support it... + if (!history._hasPushState && !atRoot) { + history.fragment = history.getFragment(null, true); + history.location.replace(history.root + history.location.search + '#' + history.fragment); + // Return immediately as browser will do redirect to new url + return true; + + // Or if we've started out with a hash-based route, but we're currently + // in a browser where it could be `pushState`-based instead... + } else if (history._hasPushState && atRoot && loc.hash) { + this.fragment = history.getHash().replace(routeStripper, ''); + this.history.replaceState({}, document.title, history.root + history.fragment + loc.search); + } + } + + if (!history.options.silent) { + return history.loadUrl(); + } +}; + +history.deactivate = function () { + window.onpopstate = null; + window.removeEventListener('hashchange', history.checkUrl); + clearInterval(history._checkUrlInterval); + history.active = false; +}; + +history.checkUrl = function () { + var current = history.getFragment(); + if (current === history.fragment && history.iframe) { + current = history.getFragment(history.getHash(history.iframe)); + } + + if (current === history.fragment) { + return false; + } + + if (history.iframe) { + history.navigate(current, false); + } + + history.loadUrl(); +}; + +history.loadUrl = function (fragmentOverride) { + var fragment = history.fragment = history.getFragment(fragmentOverride); + + return history.options.routeHandler ? + history.options.routeHandler(fragment) : + false; +}; + +history.navigate = function (fragment, options) { + if (!history.active) { + return false; + } + + if (options === undefined) { + options = { + trigger: true + }; + } else if (typeof options === "boolean") { + options = { + trigger: options + }; + } + + fragment = history.getFragment(fragment || ''); + + if (history.fragment === fragment) { + return; + } + + history.fragment = fragment; + + var url = history.root + fragment; + + // Don't include a trailing slash on the root. + if (fragment === '' && url !== '/') { + url = url.slice(0, -1); + } + + // If pushState is available, we use it to set the fragment as a real URL. + if (history._hasPushState) { + history.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url); + + // If hash changes haven't been explicitly disabled, update the hash + // fragment to store history. + } else if (history._wantsHashChange) { + updateHash(history.location, fragment, options.replace); + + if (history.iframe && (fragment !== history.getFragment(history.getHash(history.iframe)))) { + // Opening and closing the iframe tricks IE7 and earlier to push a + // history entry on hash-tag change. When replace is true, we don't + // want history. + if (!options.replace) { + history.iframe.document.open().close(); + } + + updateHash(history.iframe.location, fragment, options.replace); + } + + // If you've told us that you explicitly don't want fallback hashchange- + // based history, then `navigate` becomes a page refresh. + } else { + return history.location.assign(url); + } + + if (options.trigger) { + return history.loadUrl(fragment); + } +}; + +history.navigateBack = function () { + history.history.back(); +}; + +export = history; \ No newline at end of file diff --git a/src/router.js b/src/router.js index 9d09f92..02e27db 100644 --- a/src/router.js +++ b/src/router.js @@ -1,31 +1,67 @@ import Pipeline from './pipeline'; +import history from './history'; +import extend from './util'; -export class ChildRouter { - +function stripParametersFromRoute(route) { + var colonIndex = route.indexOf(':'); + var length = colonIndex > 0 ? colonIndex - 1 : route.length; + return route.substring(0, length); } -function ensureRoute(config){ - config.route = config.route || config.name || config.module; +function reconstructUrl(instruction) { + if(!instruction.queryString) { + return instruction.fragment; + } + + return instruction.fragment + '?' + instruction.queryString; } export class Router{ - constructor(){ - this.recognizer = new RouteRecognizer(); - this.queue = []; - this.isProcessing = false; - this.currentInstruction = null; - this.currentActivation = null; + constructor(parent:Router=null){ + this.parent = parent; + this.reset(); } get isNavigating(){ return false; } - map(config){ - config = Array.isArray(config) ? config : [config]; - config.forEach((x) => { ensureRoute(x); }); + get navigationModel(){ + if(this._needsNavModelBuild){ + var nav = [], routes = this.routes; + var fallbackOrder = 100; + + for (var i = 0; i < routes.length; i++) { + var current = routes[i]; + + if (current.nav) { + if (!(typeof current.nav == 'number')) { + current.nav = ++fallbackOrder; + } + + nav.push(current); + } + } - this.recognizer.add(config.map((x) => {path:x.route, handler: x})); + nav.sort(function (a, b) { return a.nav - b.nav; }); + this._navigationModel = nav; + this._needsNavModelBuild = false; + } + + return this._navigationModel; + } + + navigate(fragment, options) { + if (fragment && fragment.indexOf('://') != -1) { + window.location.href = fragment; + return true; + } + + return history.navigate(fragment, options); + }; + + navigateBack() { + history.navigateBack(); } loadUrl(url){ @@ -33,15 +69,28 @@ export class Router{ if(results.length){ var first = results[0]; - - this.queueInstruction({ - fragment:route, //might need to split query string... - config:first.handler, + var fragment = url; //split query string... + var queryString = url; + var instruction = { + fragment:fragment, + queryString: queryString params:first.params, queryParams:first.queryParams - }); + }; + + if(typeof first.handler == 'function'){ + first.handler(instruction); + }else{ + instruction.config = first.handler; + this.queueInstruction(instruction); + } }else{ - //not found + //log('Route Not Found'); + //this.trigger('router:route:not-found', url, this); + + if (this.currentInstruction) { + history.navigate(reconstructUrl(this.currentInstruction), { trigger: false, replace: true }); + } } } @@ -86,4 +135,181 @@ export class Router{ this.dequeueInstruction(); }); } -} + + map(route, config) { + if (Array.isArray(route)) { + for (var i = 0; i < route.length; i++) { + this.map(route[i]); + } + + return this; + } + + if (typeof route == string) { + if (!config) { + config = {}; + } else if (typeof config == 'string') { + config = { moduleId: config }; + } + + config.route = route; + } else { + config = route; + } + + return this.mapRoute(config); + } + + mapRoute(config) { + if (Array.isArray(config.route)) { + var isActive = false; + + for (var i = 0, length = config.route.length; i < length; i++) { + var current = extend({}, config); + + current.route = config.route[i]; + + Object.defineProperty(current, 'isActive', { + get: function() { + return isActive; + }, + set: function(value) { + isActive = value; + } + }); + + if (i > 0) { + delete current.nav; + } + + this.configureRoute(current); + } + } else { + this.configureRoute(config); + } + + return this; + } + + configureRoute(config) { + this._needsNavModelBuild = true; + + //this.trigger('router:route:before-config', config, this); + + config.name = config.name || this.deriveName(config); + config.route = config.route || this.deriveRoute(config); + config.title = config.title || this.deriveTitle(config); + config.moduleId = config.moduleId || this.deriveModuleId(config); + + this.ensureHash(config); + + if(!('isActive' in config)) { + config.isActive = false; + } + + //this.trigger('router:route:after-config', config, this); + + this.routes.push(config); + this.recognizer.add([{path:config.route, handler: config}]); + } + + mapUnknownRoutes(config, replaceRoute){ + var catchAllRoute = "*path"; + + var callback = (instruction) => { + instruction.config = {}; + + if (!config) { + instruction.config.moduleId = instruction.fragment; + } else if (typeof config == 'string') { + instruction.config.moduleId = config; + if (replaceRoute) { + history.navigate(replaceRoute, { trigger: false, replace: true }); + } + } else if (typeof config == 'function') { + var result = config(instruction); + if (result && result.then) { + result.then(() => { + //this.trigger('router:route:before-config', instruction.config, this); + //this.trigger('router:route:after-config', instruction.config, this); + this.queueInstruction(instruction); + }); + + return; + } + } else { + instruction.config = config; + instruction.config.route = catchAllRoute; + } + + //this.trigger('router:route:before-config', instruction.config, this); + //this.trigger('router:route:after-config', instruction.config, this); + this.queueInstruction(instruction); + }; + + this.recognizer.add([{path:catchAllRoute, handler: callback}]); + + return this; + } + + deriveName(config){ + return config.title || (config.route ? stripParametersFromRoute(config.route) : config.moduleId); + } + + deriveRoute(config){ + return config.moduleId || config.name; + } + + deriveTitle(config){ + var value = config.name; + return value.substring(0, 1).toUpperCase() + value.substring(1); + } + + deriveModuleId(config){ + return stripParametersFromRoute(config.route); + } + + ensureLink(config){ + var that = this; + + if(config.link){ + return; + } + + Object.defineProperty(config, 'link', { + get:function(){ + if(that.parent && that.parent.activeInstruction){ + var instruction = that.parent.activeInstruction, + link = instruction.config.link + '/' + config.route; + + if (history._hasPushState) { + link = '/' + link; + } + + link = link.replace('//', '/').replace('//', '/'); + return link; + } + + if (history._hasPushState) { + return config.route; + } + + return "#" + config.route; + } + }); + } + + reset() { + this.recognizer = new RouteRecognizer(); + this.routes = []; + this.queue = []; + this.isProcessing = false; + this.currentInstruction = null; + this.currentActivation = null; + delete this.options; + }; + + createChildRouter() { + return new Router(this); + }; +} \ No newline at end of file diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..4a21f73 --- /dev/null +++ b/src/util.js @@ -0,0 +1,15 @@ +export function extend(obj) { + var rest = slice.call(arguments, 1); + + for (var i = 0; i < rest.length; i++) { + var source = rest[i]; + + if (source) { + for (var prop in source) { + obj[prop] = source[prop]; + } + } + } + + return obj; +} \ No newline at end of file