Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Merge branch 'pjax'

  • Loading branch information...
commit 8e6506778121141cf3780d2d775788d58641fb6e 2 parents 0f6400e + 5605db9
@ericf ericf authored
View
398 build/pjax-base/pjax-base-debug.js
@@ -1,22 +1,93 @@
YUI.add('pjax-base', function(Y) {
-var win = Y.config.win,
-
- CLASS_PJAX = Y.ClassNameManager.getClassName('pjax'),
+/**
+`Y.Router` extension that provides the core plumbing for enhanced navigation
+implemented using the pjax technique (HTML5 `pushState` + Ajax).
+
+@submodule pjax-base
+@since 3.5.0
+**/
+
+var win = Y.config.win,
+ location = win.location,
+
+ Lang = Y.Lang,
+
+ // The CSS class name used to filter link clicks from only the links which
+ // the pjax enhanced navigation should be used.
+ CLASS_PJAX = Y.ClassNameManager.getClassName('pjax'),
+
+ /**
+ Fired when navigating to the specified URL is being enhanced by the router.
+
+ When the `navigate()` method is called or a "pjax" link is clicked, this
+ event will be fired if: the browser is HTML5-history enabled, and the router
+ has a route-handler for the specified URL.
+
+ This is a useful event to listen to for adding a visual loading indicator
+ while the route handlers are busy handling the URL change.
+
+ @event navigate
+ @param {String} url The URL that the router will dispatch to its route
+ handlers in order to fulfill the enhanced navigation "request".
+ @param {Event} [originEvent] The event that caused the navigation, usually
+ this would be a click event from a "pjax" anchor element.
+ @param {Boolean} [replace] Whether or not the current history entry will be
+ replaced, or a new entry will be created. Will default to `true` if the
+ specified `url` is the same as the current URL.
+ @param {Boolean} [force=false] Whether the enhanced navigation should occur
+ even in browsers without HTML5 history.
+ **/
EVT_NAVIGATE = 'navigate';
-// PjaxBase is a mixin for Router.
+/**
+`Y.Router` extension that provides the core plumbing for enhanced navigation
+implemented using the pjax technique (HTML5 `pushState` + Ajax).
+
+This makes it easy to enhance the navigation between the URLs of an application
+in HTML5 history capable browsers by delegating to the router to fulfill the
+"request" and seamlessly falling-back to using standard full-page reloads in
+older, less-capable browsers.
+
+@class PjaxBase
+@extensionfor Router
+**/
function PjaxBase() {}
PjaxBase.prototype = {
- // -- Properties -----------------------------------------------------------
- _resolved: {},
- _regexUrl: /^((?:([^:]+):(?:\/\/)?|\/\/)[^\/]*)?([^?#]*)(.*)$/i,
+ // -- Protected Properties -------------------------------------------------
+
+ /**
+ Holds the delegated pjax-link click handler.
+
+ @property _pjaxEvents
+ @type EventHandle
+ @default undefined
+ @protected
+ **/
+
+ /**
+ Regex used to break-up a URL string around the URL's path.
+
+ Subpattern captures:
+
+ 1. Origin, everything before the URL's path-part.
+ 2. The URL's path-part.
+ 3. Suffix, everything after the URL's path-part.
+
+ @property _regexURL
+ @type RegExp
+ @protected
+ **/
+ _regexURL: /^((?:[^\/#?:]+:\/\/|\/\/)[^\/]*)?([^?#]*)(.*)$/,
// -- Lifecycle Methods ----------------------------------------------------
initializer: function () {
this.publish(EVT_NAVIGATE, {defaultFn: this._defNavigateFn});
+ // Pjax is all about progressively-enhancing the navigation between
+ // "pages", so by default we only want to handle and route link clicks
+ // in HTML5 `pushState`-compatible browsers.
if (this.get('html5')) {
this._pjaxBindUI();
}
@@ -26,138 +97,349 @@ PjaxBase.prototype = {
this._pjaxEvents && this._pjaxEvents.detach();
},
- // -- Public Prototype Methods ---------------------------------------------
+ // -- Public Methods -------------------------------------------------------
+
+ /**
+ Navigates to the specified URL if there is a router-handler that matches. In
+ browsers capable of using HTML5 history, the navigation will be enhanced by
+ firing the `navigate` and having the router handle the "request". Non-HTML5
+ browsers will navigate to the new URL via manipulation of `window.location`.
+
+ When there is a route-handler for the specified URL and it is being
+ navigated to, this method will return `true`, otherwise it will return
+ `false`.
+
+ **Note:** The specified URL _must_ be of the same origin as the current URL,
+ otherwise an error will be logged and the navigation will not be performed.
+ This is intended as both a security constraint and an purposely imposed
+ limitation as it does not make sense to tell the router to navigate to some
+ URL on a different scheme, host, or port.
+
+ @method navigate
+ @param {String} url The URL to navigate to. This must be of the same-origin
+ as the current URL.
+ @param {Object} [options] Additional options to configure the navigation,
+ these are mixed into the `navigate` event facade.
+ @param {Boolean} [options.replace] Whether or not the current history
+ entry will be replaced, or a new entry will be created. Will default
+ to `true` if the specified `url` is the same as the current URL.
+ @param {Boolean} [options.force=false] Whether the enhanced navigation
+ should occur even in browsers without HTML5 history.
+ @return {Boolean} `true` if the URL was navigated to, `false` otherwise.
+ **/
navigate: function (url, options) {
- options || (options = {});
- options.url = url;
+ // The `_navigate()` method expects fully-resolved URLs.
+ url = this._resolveURL(url);
- this.fire(EVT_NAVIGATE, options);
+ if (this._navigate(url, options)) {
+ return true;
+ }
+
+ if (!this._hasSameOrigin(url)) {
+ Y.error('Security error: The new URL must be of the same origin as the current URL.');
+ }
+
+ return false;
},
- // -- Protected Prototype Methods ------------------------------------------
+ // -- Protected Methods ----------------------------------------------------
+
+ /**
+ Returns the current path root after popping-off the last path segment making
+ it useful for resolving other URL paths against.
+
+ The path root will always begin and end with a '/'.
+
+ @method _getRoot
+ @return {String} The URL's path root.
+ @protected
+ **/
_getRoot: function () {
- var segments = (win && win.location.pathname.split('/')) || [];
+ var slash = '/',
+ path = location.pathname,
+ segments;
+
+ if (path.charAt(path.length - 1) === slash) {
+ return path;
+ }
+
+ segments = path.split(slash);
segments.pop();
- return segments.join('/');
+
+ return segments.join(slash) + slash;
},
+ /**
+ Navigates to the specified URL if there is a router-handler that matches. In
+ browsers capable of using HTML5 history, the navigation will be enhanced by
+ firing the `navigate` and having the router handle the "request". Non-HTML5
+ browsers will navigate to the new URL via manipulation of `window.location`.
+
+ When there is a route-handler for the specified URL and it is being
+ navigated to, this method will return `true`, otherwise it will return
+ `false`.
+
+ The enhanced navigation flow can be forced causing all navigation to route
+ through the router; but this is not advised as it can will produce less
+ desirable hash-based URLs in non-HTML5 browsers.
+
+ @method _navigate
+ @param {String} url The fully-resolved URL that the router should dispatch
+ to its route handlers to fulfill the enhanced navigation "request", or use
+ to update `window.location` in non-HTML5 history capable browsers.
+ @param {Object} [options] Additional options to configure the navigation,
+ these are mixed into the `navigate` event facade.
+ @param {Boolean} [options.replace] Whether or not the current history
+ entry will be replaced, or a new entry will be created. Will default
+ to `true` if the specified `url` is the same as the current URL.
+ @param {Boolean} [options.force=false] Whether the enhanced navigation
+ should occur even in browsers without HTML5 history.
+ @protected
+ **/
+ _navigate: function (url, options) {
+ // Navigation can only be enhanced if there is a route-handler.
+ if (!this.hasRoute(url)) {
+ return false;
+ }
+
+ options || (options = {});
+ options.url = url;
+
+ // When navigating to the same URL as the current URL, behave like a
+ // browser and replace the history entry instead of creating a new one.
+ Lang.isValue(options.replace) || (options.replace = url === this._getURL());
+
+ // The `navigate` event will only fire and therefore enhance the
+ // navigation to the new URL in HTML5 history enabled browsers or when
+ // forced. Otherwise it will fallback to assigning or replacing the URL
+ // on `window.location`.
+ if (this.get('html5') || options.force) {
+ this.fire(EVT_NAVIGATE, options);
+ } else {
+ if (options.replace) {
+ location.replace(url);
+ } else {
+ win.location = url;
+ }
+ }
+
+ return true;
+ },
+
+ /**
+ Returns a normalized path, riding it of any '..' segments and properly
+ handling leading and trailing '/'s.
+
+ @method _normalizePath
+ @param {String} path The URL path to normalize.
+ @return {String} The normalized path.
+ @protected
+ **/
_normalizePath: function (path) {
var dots = '..',
slash = '/',
- i, len, normalized, parts, part, stack;
+ i, len, normalized, segments, segment, stack;
if (!path) {
- return path;
+ return slash;
}
- parts = path.split(slash);
- stack = [];
+ segments = path.split(slash);
+ stack = [];
- for (i = 0, len = parts.length; i < len; ++i) {
- part = parts[i];
+ for (i = 0, len = segments.length; i < len; ++i) {
+ segment = segments[i];
- if (part === dots) {
+ if (segment === dots) {
stack.pop();
- } else if (part) {
- stack.push(part);
+ } else if (segment) {
+ stack.push(segment);
}
}
- normalized = stack.join(slash);
+ normalized = slash + stack.join(slash);
- // Append a slash if necessary.
- if (path.charAt(path.length - 1) === slash) {
+ // Append trailing slash if necessary.
+ if (normalized !== slash && path.charAt(path.length - 1) === slash) {
normalized += slash;
}
return normalized;
},
+ /**
+ Binds the delegation of link-click events that match the `linkSelector` to
+ the `_onLinkClick()` handler.
+
+ By default this method will only be called if the browser is capable of
+ using HTML5 history.
+
+ @method _pjaxBindUI
+ @protected
+ **/
_pjaxBindUI: function () {
+ // Only bind link if we haven't already.
if (!this._pjaxEvents) {
this._pjaxEvents = Y.one('body').delegate('click',
this._onLinkClick, this.get('linkSelector'), this);
}
},
- _resolvePath: function (path, root) {
- root || (root = this._getRoot());
+ /**
+ Returns the normalized result of resolving the `path` against the current
+ path.
+
+ A host-relative `path` (one that begins with '/') is assumed to be resolved
+ and is returned as is. Falsy values for `path` will return just the current
+ path.
+ @method _resolvePath
+ @param {String} path The URL path to resolve.
+ @return {String} The resolved path.
+ @protected
+ **/
+ _resolvePath: function (path) {
if (!path) {
- return root;
+ return this._getPath();
}
- // Path is host relative.
+ // Path is host-relative and assumed to be resolved and normalized,
+ // meaning silly paths like: '/foo/../bar/' will be returned as-is.
if (path.charAt(0) === '/') {
return path;
}
- return this._normalizePath(root + '/' + path);
+ return this._normalizePath(this._getRoot() + path);
},
- _resolveUrl: function (url) {
- var self = this,
- root = self._getRoot(),
- resolved, resolvedUrl;
-
- resolved = self._resolved[root] || (self._resolved[root] = {});
- resolvedUrl = resolved[url];
-
- if (resolvedUrl) {
- return resolvedUrl;
+ /**
+ Resolves the specified URL against the current URL.
+
+ This method resolves URLs like a browser does and will always return an
+ absolute URL. When the specified URL is already absolute, it is assumed to
+ be fully resolved and is simply returned as is. Scheme-relative URLs are
+ prefixed with the current protocol. Relative URLs are giving the current
+ URL's origin and are resolved and normalized against the current path-root.
+
+ @method _resolveURL
+ @param {String} url The URL to resolve.
+ @return {String} The resolved URL.
+ @protected
+ **/
+ _resolveURL: function (url) {
+ var parts = url && url.match(this._regexURL),
+ origin, path, suffix;
+
+ if (!parts) {
+ return this._getURL();
}
- function resolve(match, prefix, scheme, path, suffix) {
- if (scheme && scheme.toLowerCase().indexOf('http') !== 0) {
- return match;
+ origin = parts[1];
+ path = parts[2];
+ suffix = parts[3];
+
+ // Absolute and scheme-relative URLs are assumed to be fully-resolved.
+ if (origin) {
+ // Prepend the current scheme for scheme-relative URLs.
+ if (origin.indexOf('//') === 0) {
+ origin = location.protocol + origin;
}
- return (prefix || '') + self._resolvePath(path, root) + (suffix || '');
+ return origin + (path || '/') + (suffix + '');
}
- // Cache resolved URL.
- resolvedUrl = resolved[url] = url.replace(self._regexUrl, resolve);
-
- return resolvedUrl;
+ return this._getOrigin() + this._resolvePath(path) + (suffix || '');
},
// -- Protected Event Handlers ---------------------------------------------
+
+ /**
+ Default handler for the `navigate` event.
+
+ Adds a new history entry or replaces the current entry for the specified URL
+ and will scroll the page to the top if configured to do so.
+
+ @method _defNavigateFn
+ @param {EventFacade} e
+ @protected
+ **/
_defNavigateFn: function (e) {
- this.save(this._resolveUrl(e.url));
+ this[e.replace ? 'replace' : 'save'](e.url);
- if (this.get('scrollToTop') && Y.config.win) {
+ if (win && this.get('scrollToTop')) {
// Scroll to the top of the page. The timeout ensures that the
// scroll happens after navigation begins, so that the current
// scroll position will be restored if the user clicks the back
// button.
setTimeout(function () {
- Y.config.win.scroll(0, 0);
+ win.scroll(0, 0);
}, 1);
}
},
+ /**
+ Handler for the delegated link-click events which match the `linkSelector`.
+
+ This will attempt to enhance the navigation to the link element's `href` by
+ passing the URL to the `_navigate()` method. When the navigation is being
+ enhanced, the default action is prevented.
+
+ If the user clicks a link with the middle/right mouse buttons, or is holding
+ down the Ctrl or Command keys, this method's behavior is not applied and
+ allows the native behavior to occur. Similarly, if the router is not capable
+ or handling the URL because no route-handlers match, the link click will
+ behave natively.
+
+ @method _onLinkClick
+ @param {EventFacade} e
+ @protected
+ **/
_onLinkClick: function (e) {
- var url = this._resolveUrl(e.currentTarget.get('href'));
+ var url;
// Allow the native behavior on middle/right-click, or when Ctrl or
// Command are pressed.
if (e.button !== 1 || e.ctrlKey || e.metaKey) { return; }
- // Do nothing if there's no matching route for this URL.
- if (!this.hasRoute(url)) { return; }
+ // All browsers fully resolve an anchor's `href` property.
+ url = e.currentTarget.get('href');
- e.preventDefault();
-
- this.navigate(url, {originEvent: e});
+ // Try and navigate to the URL via the router, and prevent the default
+ // link-click action if we do.
+ url && this._navigate(url, {originEvent: e}) && e.preventDefault();
}
};
PjaxBase.ATTRS = {
+ /**
+ This selector is used so only the click events who's links match will have
+ the enhanced navigation behavior applied.
+
+ When a link being clicked on matches this selector, the browsers default of
+ navigating to the URL by doing a full-page reload will be prevented;
+ instead, navigating to the URL will be enhanced by have the router fulfill
+ the "request" by updating the URL and content of the page.
+
+ @attribute linkSelector
+ @type String|Function
+ @default `'a.pjax'`
+ @initOnly
+ **/
linkSelector: {
value : 'a.' + CLASS_PJAX,
writeOnce: 'initOnly'
},
+ /**
+ Whether the page should be scrolled to the top after navigating to a URL.
+
+ When the user clicks the browser's back button, the previous scroll-position
+ will be maintained.
+
+ @attribute scrollToTop
+ @type Boolean
+ @default `true`
+ **/
scrollToTop: {
value: true
}
View
2  build/pjax-base/pjax-base-min.js
@@ -1 +1 @@
-YUI.add("pjax-base",function(e){var c=e.config.win,d=e.ClassNameManager.getClassName("pjax"),a="navigate";function b(){}b.prototype={_resolved:{},_regexUrl:/^((?:([^:]+):(?:\/\/)?|\/\/)[^\/]*)?([^?#]*)(.*)$/i,initializer:function(){this.publish(a,{defaultFn:this._defNavigateFn});if(this.get("html5")){this._pjaxBindUI();}},destructor:function(){this._pjaxEvents&&this._pjaxEvents.detach();},navigate:function(g,f){f||(f={});f.url=g;this.fire(a,f);},_getRoot:function(){var f=(c&&c.location.pathname.split("/"))||[];f.pop();return f.join("/");},_normalizePath:function(o){var l="..",h="/",j,k,n,g,f,m;if(!o){return o;}g=o.split(h);m=[];for(j=0,k=g.length;j<k;++j){f=g[j];if(f===l){m.pop();}else{if(f){m.push(f);}}}n=m.join(h);if(o.charAt(o.length-1)===h){n+=h;}return n;},_pjaxBindUI:function(){if(!this._pjaxEvents){this._pjaxEvents=e.one("body").delegate("click",this._onLinkClick,this.get("linkSelector"),this);}},_resolvePath:function(g,f){f||(f=this._getRoot());if(!g){return f;}if(g.charAt(0)==="/"){return g;}return this._normalizePath(f+"/"+g);},_resolveUrl:function(i){var h=this,g=h._getRoot(),f,k;f=h._resolved[g]||(h._resolved[g]={});k=f[i];if(k){return k;}function j(m,n,l,p,o){if(l&&l.toLowerCase().indexOf("http")!==0){return m;}return(n||"")+h._resolvePath(p,g)+(o||"");}k=f[i]=i.replace(h._regexUrl,j);return k;},_defNavigateFn:function(f){this.save(this._resolveUrl(f.url));if(this.get("scrollToTop")&&e.config.win){setTimeout(function(){e.config.win.scroll(0,0);},1);}},_onLinkClick:function(g){var f=this._resolveUrl(g.currentTarget.get("href"));if(g.button!==1||g.ctrlKey||g.metaKey){return;}if(!this.hasRoute(f)){return;}g.preventDefault();this.navigate(f,{originEvent:g});}};b.ATTRS={linkSelector:{value:"a."+d,writeOnce:"initOnly"},scrollToTop:{value:true}};e.PjaxBase=b;},"@VERSION@",{requires:["classnamemanager","node-event-delegate","router"]});
+YUI.add("pjax-base",function(g){var e=g.config.win,a=e.location,c=g.Lang,f=g.ClassNameManager.getClassName("pjax"),b="navigate";function d(){}d.prototype={_regexURL:/^((?:[^\/#?:]+:\/\/|\/\/)[^\/]*)?([^?#]*)(.*)$/,initializer:function(){this.publish(b,{defaultFn:this._defNavigateFn});if(this.get("html5")){this._pjaxBindUI();}},destructor:function(){this._pjaxEvents&&this._pjaxEvents.detach();},navigate:function(i,h){i=this._resolveURL(i);if(this._navigate(i,h)){return true;}if(!this._hasSameOrigin(i)){g.error("Security error: The new URL must be of the same origin as the current URL.");}return false;},_getRoot:function(){var i="/",j=a.pathname,h;if(j.charAt(j.length-1)===i){return j;}h=j.split(i);h.pop();return h.join(i)+i;},_navigate:function(i,h){if(!this.hasRoute(i)){return false;}h||(h={});h.url=i;c.isValue(h.replace)||(h.replace=i===this._getURL());if(this.get("html5")||h.force){this.fire(b,h);}else{if(h.replace){a.replace(i);}else{e.location=i;}}return true;},_normalizePath:function(q){var n="..",h="/",j,m,p,k,l,o;if(!q){return h;}k=q.split(h);o=[];for(j=0,m=k.length;j<m;++j){l=k[j];if(l===n){o.pop();}else{if(l){o.push(l);}}}p=h+o.join(h);if(p!==h&&q.charAt(q.length-1)===h){p+=h;}return p;},_pjaxBindUI:function(){if(!this._pjaxEvents){this._pjaxEvents=g.one("body").delegate("click",this._onLinkClick,this.get("linkSelector"),this);}},_resolvePath:function(h){if(!h){return this._getPath();}if(h.charAt(0)==="/"){return h;}return this._normalizePath(this._getRoot()+h);},_resolveURL:function(i){var l=i&&i.match(this._regexURL),h,k,j;if(!l){return this._getURL();}h=l[1];k=l[2];j=l[3];if(h){if(h.indexOf("//")===0){h=a.protocol+h;}return h+(k||"/")+(j+"");}return this._getOrigin()+this._resolvePath(k)+(j||"");},_defNavigateFn:function(h){this[h.replace?"replace":"save"](h.url);if(e&&this.get("scrollToTop")){setTimeout(function(){e.scroll(0,0);},1);}},_onLinkClick:function(i){var h;if(i.button!==1||i.ctrlKey||i.metaKey){return;}h=i.currentTarget.get("href");h&&this._navigate(h,{originEvent:i})&&i.preventDefault();}};d.ATTRS={linkSelector:{value:"a."+f,writeOnce:"initOnly"},scrollToTop:{value:true}};g.PjaxBase=d;},"@VERSION@",{requires:["classnamemanager","node-event-delegate","router"]});
View
398 build/pjax-base/pjax-base.js
@@ -1,22 +1,93 @@
YUI.add('pjax-base', function(Y) {
-var win = Y.config.win,
-
- CLASS_PJAX = Y.ClassNameManager.getClassName('pjax'),
+/**
+`Y.Router` extension that provides the core plumbing for enhanced navigation
+implemented using the pjax technique (HTML5 `pushState` + Ajax).
+
+@submodule pjax-base
+@since 3.5.0
+**/
+
+var win = Y.config.win,
+ location = win.location,
+
+ Lang = Y.Lang,
+
+ // The CSS class name used to filter link clicks from only the links which
+ // the pjax enhanced navigation should be used.
+ CLASS_PJAX = Y.ClassNameManager.getClassName('pjax'),
+
+ /**
+ Fired when navigating to the specified URL is being enhanced by the router.
+
+ When the `navigate()` method is called or a "pjax" link is clicked, this
+ event will be fired if: the browser is HTML5-history enabled, and the router
+ has a route-handler for the specified URL.
+
+ This is a useful event to listen to for adding a visual loading indicator
+ while the route handlers are busy handling the URL change.
+
+ @event navigate
+ @param {String} url The URL that the router will dispatch to its route
+ handlers in order to fulfill the enhanced navigation "request".
+ @param {Event} [originEvent] The event that caused the navigation, usually
+ this would be a click event from a "pjax" anchor element.
+ @param {Boolean} [replace] Whether or not the current history entry will be
+ replaced, or a new entry will be created. Will default to `true` if the
+ specified `url` is the same as the current URL.
+ @param {Boolean} [force=false] Whether the enhanced navigation should occur
+ even in browsers without HTML5 history.
+ **/
EVT_NAVIGATE = 'navigate';
-// PjaxBase is a mixin for Router.
+/**
+`Y.Router` extension that provides the core plumbing for enhanced navigation
+implemented using the pjax technique (HTML5 `pushState` + Ajax).
+
+This makes it easy to enhance the navigation between the URLs of an application
+in HTML5 history capable browsers by delegating to the router to fulfill the
+"request" and seamlessly falling-back to using standard full-page reloads in
+older, less-capable browsers.
+
+@class PjaxBase
+@extensionfor Router
+**/
function PjaxBase() {}
PjaxBase.prototype = {
- // -- Properties -----------------------------------------------------------
- _resolved: {},
- _regexUrl: /^((?:([^:]+):(?:\/\/)?|\/\/)[^\/]*)?([^?#]*)(.*)$/i,
+ // -- Protected Properties -------------------------------------------------
+
+ /**
+ Holds the delegated pjax-link click handler.
+
+ @property _pjaxEvents
+ @type EventHandle
+ @default undefined
+ @protected
+ **/
+
+ /**
+ Regex used to break-up a URL string around the URL's path.
+
+ Subpattern captures:
+
+ 1. Origin, everything before the URL's path-part.
+ 2. The URL's path-part.
+ 3. Suffix, everything after the URL's path-part.
+
+ @property _regexURL
+ @type RegExp
+ @protected
+ **/
+ _regexURL: /^((?:[^\/#?:]+:\/\/|\/\/)[^\/]*)?([^?#]*)(.*)$/,
// -- Lifecycle Methods ----------------------------------------------------
initializer: function () {
this.publish(EVT_NAVIGATE, {defaultFn: this._defNavigateFn});
+ // Pjax is all about progressively-enhancing the navigation between
+ // "pages", so by default we only want to handle and route link clicks
+ // in HTML5 `pushState`-compatible browsers.
if (this.get('html5')) {
this._pjaxBindUI();
}
@@ -26,138 +97,349 @@ PjaxBase.prototype = {
this._pjaxEvents && this._pjaxEvents.detach();
},
- // -- Public Prototype Methods ---------------------------------------------
+ // -- Public Methods -------------------------------------------------------
+
+ /**
+ Navigates to the specified URL if there is a router-handler that matches. In
+ browsers capable of using HTML5 history, the navigation will be enhanced by
+ firing the `navigate` and having the router handle the "request". Non-HTML5
+ browsers will navigate to the new URL via manipulation of `window.location`.
+
+ When there is a route-handler for the specified URL and it is being
+ navigated to, this method will return `true`, otherwise it will return
+ `false`.
+
+ **Note:** The specified URL _must_ be of the same origin as the current URL,
+ otherwise an error will be logged and the navigation will not be performed.
+ This is intended as both a security constraint and an purposely imposed
+ limitation as it does not make sense to tell the router to navigate to some
+ URL on a different scheme, host, or port.
+
+ @method navigate
+ @param {String} url The URL to navigate to. This must be of the same-origin
+ as the current URL.
+ @param {Object} [options] Additional options to configure the navigation,
+ these are mixed into the `navigate` event facade.
+ @param {Boolean} [options.replace] Whether or not the current history
+ entry will be replaced, or a new entry will be created. Will default
+ to `true` if the specified `url` is the same as the current URL.
+ @param {Boolean} [options.force=false] Whether the enhanced navigation
+ should occur even in browsers without HTML5 history.
+ @return {Boolean} `true` if the URL was navigated to, `false` otherwise.
+ **/
navigate: function (url, options) {
- options || (options = {});
- options.url = url;
+ // The `_navigate()` method expects fully-resolved URLs.
+ url = this._resolveURL(url);
- this.fire(EVT_NAVIGATE, options);
+ if (this._navigate(url, options)) {
+ return true;
+ }
+
+ if (!this._hasSameOrigin(url)) {
+ Y.error('Security error: The new URL must be of the same origin as the current URL.');
+ }
+
+ return false;
},
- // -- Protected Prototype Methods ------------------------------------------
+ // -- Protected Methods ----------------------------------------------------
+
+ /**
+ Returns the current path root after popping-off the last path segment making
+ it useful for resolving other URL paths against.
+
+ The path root will always begin and end with a '/'.
+
+ @method _getRoot
+ @return {String} The URL's path root.
+ @protected
+ **/
_getRoot: function () {
- var segments = (win && win.location.pathname.split('/')) || [];
+ var slash = '/',
+ path = location.pathname,
+ segments;
+
+ if (path.charAt(path.length - 1) === slash) {
+ return path;
+ }
+
+ segments = path.split(slash);
segments.pop();
- return segments.join('/');
+
+ return segments.join(slash) + slash;
},
+ /**
+ Navigates to the specified URL if there is a router-handler that matches. In
+ browsers capable of using HTML5 history, the navigation will be enhanced by
+ firing the `navigate` and having the router handle the "request". Non-HTML5
+ browsers will navigate to the new URL via manipulation of `window.location`.
+
+ When there is a route-handler for the specified URL and it is being
+ navigated to, this method will return `true`, otherwise it will return
+ `false`.
+
+ The enhanced navigation flow can be forced causing all navigation to route
+ through the router; but this is not advised as it can will produce less
+ desirable hash-based URLs in non-HTML5 browsers.
+
+ @method _navigate
+ @param {String} url The fully-resolved URL that the router should dispatch
+ to its route handlers to fulfill the enhanced navigation "request", or use
+ to update `window.location` in non-HTML5 history capable browsers.
+ @param {Object} [options] Additional options to configure the navigation,
+ these are mixed into the `navigate` event facade.
+ @param {Boolean} [options.replace] Whether or not the current history
+ entry will be replaced, or a new entry will be created. Will default
+ to `true` if the specified `url` is the same as the current URL.
+ @param {Boolean} [options.force=false] Whether the enhanced navigation
+ should occur even in browsers without HTML5 history.
+ @protected
+ **/
+ _navigate: function (url, options) {
+ // Navigation can only be enhanced if there is a route-handler.
+ if (!this.hasRoute(url)) {
+ return false;
+ }
+
+ options || (options = {});
+ options.url = url;
+
+ // When navigating to the same URL as the current URL, behave like a
+ // browser and replace the history entry instead of creating a new one.
+ Lang.isValue(options.replace) || (options.replace = url === this._getURL());
+
+ // The `navigate` event will only fire and therefore enhance the
+ // navigation to the new URL in HTML5 history enabled browsers or when
+ // forced. Otherwise it will fallback to assigning or replacing the URL
+ // on `window.location`.
+ if (this.get('html5') || options.force) {
+ this.fire(EVT_NAVIGATE, options);
+ } else {
+ if (options.replace) {
+ location.replace(url);
+ } else {
+ win.location = url;
+ }
+ }
+
+ return true;
+ },
+
+ /**
+ Returns a normalized path, riding it of any '..' segments and properly
+ handling leading and trailing '/'s.
+
+ @method _normalizePath
+ @param {String} path The URL path to normalize.
+ @return {String} The normalized path.
+ @protected
+ **/
_normalizePath: function (path) {
var dots = '..',
slash = '/',
- i, len, normalized, parts, part, stack;
+ i, len, normalized, segments, segment, stack;
if (!path) {
- return path;
+ return slash;
}
- parts = path.split(slash);
- stack = [];
+ segments = path.split(slash);
+ stack = [];
- for (i = 0, len = parts.length; i < len; ++i) {
- part = parts[i];
+ for (i = 0, len = segments.length; i < len; ++i) {
+ segment = segments[i];
- if (part === dots) {
+ if (segment === dots) {
stack.pop();
- } else if (part) {
- stack.push(part);
+ } else if (segment) {
+ stack.push(segment);
}
}
- normalized = stack.join(slash);
+ normalized = slash + stack.join(slash);
- // Append a slash if necessary.
- if (path.charAt(path.length - 1) === slash) {
+ // Append trailing slash if necessary.
+ if (normalized !== slash && path.charAt(path.length - 1) === slash) {
normalized += slash;
}
return normalized;
},
+ /**
+ Binds the delegation of link-click events that match the `linkSelector` to
+ the `_onLinkClick()` handler.
+
+ By default this method will only be called if the browser is capable of
+ using HTML5 history.
+
+ @method _pjaxBindUI
+ @protected
+ **/
_pjaxBindUI: function () {
+ // Only bind link if we haven't already.
if (!this._pjaxEvents) {
this._pjaxEvents = Y.one('body').delegate('click',
this._onLinkClick, this.get('linkSelector'), this);
}
},
- _resolvePath: function (path, root) {
- root || (root = this._getRoot());
+ /**
+ Returns the normalized result of resolving the `path` against the current
+ path.
+
+ A host-relative `path` (one that begins with '/') is assumed to be resolved
+ and is returned as is. Falsy values for `path` will return just the current
+ path.
+ @method _resolvePath
+ @param {String} path The URL path to resolve.
+ @return {String} The resolved path.
+ @protected
+ **/
+ _resolvePath: function (path) {
if (!path) {
- return root;
+ return this._getPath();
}
- // Path is host relative.
+ // Path is host-relative and assumed to be resolved and normalized,
+ // meaning silly paths like: '/foo/../bar/' will be returned as-is.
if (path.charAt(0) === '/') {
return path;
}
- return this._normalizePath(root + '/' + path);
+ return this._normalizePath(this._getRoot() + path);
},
- _resolveUrl: function (url) {
- var self = this,
- root = self._getRoot(),
- resolved, resolvedUrl;
-
- resolved = self._resolved[root] || (self._resolved[root] = {});
- resolvedUrl = resolved[url];
-
- if (resolvedUrl) {
- return resolvedUrl;
+ /**
+ Resolves the specified URL against the current URL.
+
+ This method resolves URLs like a browser does and will always return an
+ absolute URL. When the specified URL is already absolute, it is assumed to
+ be fully resolved and is simply returned as is. Scheme-relative URLs are
+ prefixed with the current protocol. Relative URLs are giving the current
+ URL's origin and are resolved and normalized against the current path-root.
+
+ @method _resolveURL
+ @param {String} url The URL to resolve.
+ @return {String} The resolved URL.
+ @protected
+ **/
+ _resolveURL: function (url) {
+ var parts = url && url.match(this._regexURL),
+ origin, path, suffix;
+
+ if (!parts) {
+ return this._getURL();
}
- function resolve(match, prefix, scheme, path, suffix) {
- if (scheme && scheme.toLowerCase().indexOf('http') !== 0) {
- return match;
+ origin = parts[1];
+ path = parts[2];
+ suffix = parts[3];
+
+ // Absolute and scheme-relative URLs are assumed to be fully-resolved.
+ if (origin) {
+ // Prepend the current scheme for scheme-relative URLs.
+ if (origin.indexOf('//') === 0) {
+ origin = location.protocol + origin;
}
- return (prefix || '') + self._resolvePath(path, root) + (suffix || '');
+ return origin + (path || '/') + (suffix + '');
}
- // Cache resolved URL.
- resolvedUrl = resolved[url] = url.replace(self._regexUrl, resolve);
-
- return resolvedUrl;
+ return this._getOrigin() + this._resolvePath(path) + (suffix || '');
},
// -- Protected Event Handlers ---------------------------------------------
+
+ /**
+ Default handler for the `navigate` event.
+
+ Adds a new history entry or replaces the current entry for the specified URL
+ and will scroll the page to the top if configured to do so.
+
+ @method _defNavigateFn
+ @param {EventFacade} e
+ @protected
+ **/
_defNavigateFn: function (e) {
- this.save(this._resolveUrl(e.url));
+ this[e.replace ? 'replace' : 'save'](e.url);
- if (this.get('scrollToTop') && Y.config.win) {
+ if (win && this.get('scrollToTop')) {
// Scroll to the top of the page. The timeout ensures that the
// scroll happens after navigation begins, so that the current
// scroll position will be restored if the user clicks the back
// button.
setTimeout(function () {
- Y.config.win.scroll(0, 0);
+ win.scroll(0, 0);
}, 1);
}
},
+ /**
+ Handler for the delegated link-click events which match the `linkSelector`.
+
+ This will attempt to enhance the navigation to the link element's `href` by
+ passing the URL to the `_navigate()` method. When the navigation is being
+ enhanced, the default action is prevented.
+
+ If the user clicks a link with the middle/right mouse buttons, or is holding
+ down the Ctrl or Command keys, this method's behavior is not applied and
+ allows the native behavior to occur. Similarly, if the router is not capable
+ or handling the URL because no route-handlers match, the link click will
+ behave natively.
+
+ @method _onLinkClick
+ @param {EventFacade} e
+ @protected
+ **/
_onLinkClick: function (e) {
- var url = this._resolveUrl(e.currentTarget.get('href'));
+ var url;
// Allow the native behavior on middle/right-click, or when Ctrl or
// Command are pressed.
if (e.button !== 1 || e.ctrlKey || e.metaKey) { return; }
- // Do nothing if there's no matching route for this URL.
- if (!this.hasRoute(url)) { return; }
+ // All browsers fully resolve an anchor's `href` property.
+ url = e.currentTarget.get('href');
- e.preventDefault();
-
- this.navigate(url, {originEvent: e});
+ // Try and navigate to the URL via the router, and prevent the default
+ // link-click action if we do.
+ url && this._navigate(url, {originEvent: e}) && e.preventDefault();
}
};
PjaxBase.ATTRS = {
+ /**
+ This selector is used so only the click events who's links match will have
+ the enhanced navigation behavior applied.
+
+ When a link being clicked on matches this selector, the browsers default of
+ navigating to the URL by doing a full-page reload will be prevented;
+ instead, navigating to the URL will be enhanced by have the router fulfill
+ the "request" by updating the URL and content of the page.
+
+ @attribute linkSelector
+ @type String|Function
+ @default `'a.pjax'`
+ @initOnly
+ **/
linkSelector: {
value : 'a.' + CLASS_PJAX,
writeOnce: 'initOnly'
},
+ /**
+ Whether the page should be scrolled to the top after navigating to a URL.
+
+ When the user clicks the browser's back button, the previous scroll-position
+ will be maintained.
+
+ @attribute scrollToTop
+ @type Boolean
+ @default `true`
+ **/
scrollToTop: {
value: true
}
View
95 build/router/router-debug.js
@@ -8,12 +8,12 @@ Provides URL-based routing using HTML5 `pushState()` or the location hash.
**/
var HistoryHash = Y.HistoryHash,
- Lang = Y.Lang,
QS = Y.QueryString,
YArray = Y.Array,
win = Y.config.win,
location = win.location,
+ origin = location.origin || (location.protocol + '//' + location.host),
// We have to queue up pushState calls to avoid race conditions, since the
// popstate event doesn't actually provide any info on what URL it's
@@ -111,7 +111,7 @@ Y.Router = Y.extend(Router, Y.Base, {
@type RegExp
@protected
**/
- _regexPathParam: /([:*])([\w-]+)/g,
+ _regexPathParam: /([:*])([\w\-]+)/g,
/**
Regex that matches and captures the query portion of a URL, minus the
@@ -124,15 +124,15 @@ Y.Router = Y.extend(Router, Y.Base, {
_regexUrlQuery: /\?([^#]*).*$/,
/**
- Regex that matches everything before the path portion of an HTTP or HTTPS
- URL. This will be used to strip this part of the URL from a string when we
+ Regex that matches everything before the path portion of a URL (the origin).
+ This will be used to strip this part of the URL from a string when we
only want the path.
- @property _regexUrlStrip
+ @property _regexUrlOrigin
@type RegExp
@protected
**/
- _regexUrlStrip: /^https?:\/\/[^\/]*/i,
+ _regexUrlOrigin: /^(?:[^\/#?:]+:\/\/|\/\/)[^\/]*/,
// -- Lifecycle Methods ----------------------------------------------------
initializer: function (config) {
@@ -220,12 +220,20 @@ Y.Router = Y.extend(Router, Y.Base, {
Returns `true` if this router has at least one route that matches the
specified URL, `false` otherwise.
+ This method enforces the same-origin security constraint on the specified
+ `url`; any URL which is not from the same origin as the current URL will
+ always return `false`.
+
@method hasRoute
@param {String} url URL to match.
@return {Boolean} `true` if there's at least one matching route, `false`
otherwise.
**/
hasRoute: function (url) {
+ if (!this._hasSameOrigin(url)) {
+ return false;
+ }
+
return !!this.match(this.removeRoot(url)).length;
},
@@ -275,7 +283,7 @@ Y.Router = Y.extend(Router, Y.Base, {
// Strip out the non-path part of the URL, if any (e.g.
// "http://foo.com"), so that we're left with just the path.
- url = url.replace(this._regexUrlStrip, '');
+ url = url.replace(this._regexUrlOrigin, '');
if (root && url.indexOf(root) === 0) {
url = url.substring(root.length);
@@ -308,9 +316,9 @@ Y.Router = Y.extend(Router, Y.Base, {
// New URL: http://example.com/
@method replace
- @param {String} [url] URL to set. Should be a relative URL. If this
- router's `root` property is set, this URL must be relative to the
- root URL. If no URL is specified, the page's current URL will be used.
+ @param {String} [url] URL to set. This URL needs to be of the same origin as
+ the current URL. This can be a URL relative to the router's `root`
+ attribute. If no URL is specified, the page's current URL will be used.
@chainable
@see save()
**/
@@ -424,9 +432,9 @@ Y.Router = Y.extend(Router, Y.Base, {
// New URL: http://example.com/
@method save
- @param {String} [url] URL to set. Should be a relative URL. If this
- router's `root` property is set, this URL must be relative to the
- root URL. If no URL is specified, the page's current URL will be used.
+ @param {String} [url] URL to set. This URL needs to be of the same origin as
+ the current URL. This can be a URL relative to the router's `root`
+ attribute. If no URL is specified, the page's current URL will be used.
@chainable
@see replace()
**/
@@ -576,15 +584,29 @@ Y.Router = Y.extend(Router, Y.Base, {
},
/**
- Gets the current route path.
+ Gets the location origin (i.e., protocol, host, and port) as a URL.
+
+ @example
+ http://example.com
+
+ @method _getOrigin
+ @return {String} Location origin (i.e., protocol, host, and port).
+ @protected
+ **/
+ _getOrigin: function () {
+ return origin;
+ },
+
+ /**
+ Gets the current route path, relative to the `root` (if any).
@method _getPath
@return {String} Current route path.
@protected
**/
_getPath: function () {
- return (!this._html5 && this._getHashPath()) ||
- this.removeRoot(location.pathname);
+ var path = (!this._html5 && this._getHashPath()) || location.pathname;
+ return this.removeRoot(path);
},
/**
@@ -662,8 +684,8 @@ Y.Router = Y.extend(Router, Y.Base, {
@protected
**/
_getResponse: function (req) {
- // For backcompat, the response object is a function that calls `next()`
- // on the request object and returns the result.
+ // For backwards compatibility, the response object is a function that
+ // calls `next()` on the request object and returns the result.
var res = function () {
return req.next.apply(this, arguments);
};
@@ -695,15 +717,38 @@ Y.Router = Y.extend(Router, Y.Base, {
},
/**
+ Returns `true` when the specified `url` is from the same origin as the
+ current URL; i.e., the protocol, host, and port of the URLs are the same.
+
+ All host or path relative URLs are of the same origin. A scheme-relative URL
+ is first prefixed with the current scheme before being evaluated.
+
+ @method _hasSameOrigin
+ @param {String} url URL to compare origin with the current URL.
+ @return {Boolean} Whether the URL has the same origin of the current URL.
+ @protected
+ **/
+ _hasSameOrigin: function (url) {
+ var origin = ((url && url.match(this._regexUrlOrigin)) || [])[0];
+
+ // Prepend current scheme to scheme-relative URLs.
+ if (origin && origin.indexOf('//') === 0) {
+ origin = location.protocol + origin;
+ }
+
+ return !origin || origin === this._getOrigin();
+ },
+
+ /**
Joins the `root` URL to the specified _url_, normalizing leading/trailing
`/` characters.
@example
- router.root = '/foo'
+ router.set('root', '/foo');
router._joinURL('bar'); // => '/foo/bar'
router._joinURL('/bar'); // => '/foo/bar'
- router.root = '/foo/'
+ router.set('root', '/foo/');
router._joinURL('bar'); // => '/foo/bar'
router._joinURL('/bar'); // => '/foo/bar'
@@ -802,6 +847,10 @@ Y.Router = Y.extend(Router, Y.Base, {
/**
Saves a history entry using either `pushState()` or the location hash.
+ This method enforces the same-origin security constraint; attempting to save
+ a `url` that is not from the same origin as the current URL will result in
+ an error.
+
@method _save
@param {String} [url] URL for the history entry.
@param {Boolean} [replace=false] If `true`, the current history entry will
@@ -812,6 +861,12 @@ Y.Router = Y.extend(Router, Y.Base, {
_save: function (url, replace) {
var urlIsString = typeof url === 'string';
+ // Perform same-origin check on the specified URL.
+ if (urlIsString && !this._hasSameOrigin(url)) {
+ Y.error('Security error: The new URL must be of the same origin as the current URL.');
+ return this;
+ }
+
// Force _ready to true to ensure that the history change is handled
// even if _save is called before the `ready` event fires.
this._ready = true;
View
2  build/router/router-min.js
@@ -1 +1 @@
-YUI.add("router",function(a){var g=a.HistoryHash,e=a.Lang,c=a.QueryString,h=a.Array,f=a.config.win,j=f.location,i=[],d="ready";function b(){b.superclass.constructor.apply(this,arguments);}a.Router=a.extend(b,a.Base,{_regexPathParam:/([:*])([\w-]+)/g,_regexUrlQuery:/\?([^#]*).*$/,_regexUrlStrip:/^https?:\/\/[^\/]*/i,initializer:function(l){var k=this;k._html5=k.get("html5");k._routes=[];this._setRoutes(l&&l.routes?l.routes:this.get("routes"));if(k._html5){k._history=new a.HistoryHTML5({force:true});a.after("history:change",k._afterHistoryChange,k);}else{a.on("hashchange",k._afterHistoryChange,f,k);}k.publish(d,{defaultFn:k._defReadyFn,fireOnce:true,preventable:false});k.once("initializedChange",function(){a.once("load",function(){setTimeout(function(){k.fire(d,{dispatched:!!k._dispatched});},20);});});},destructor:function(){if(this._html5){a.detach("history:change",this._afterHistoryChange,this);}else{a.detach("hashchange",this._afterHistoryChange,f);}},dispatch:function(){this.once(d,function(){this._ready=true;if(this._html5&&this.upgrade()){return;}else{this._dispatch(this._getPath(),this._getURL());}});return this;},getPath:function(){return this._getPath();},hasRoute:function(k){return !!this.match(this.removeRoot(k)).length;},match:function(k){return h.filter(this._routes,function(l){return k.search(l.regex)>-1;});},removeRoot:function(l){var k=this.get("root");l=l.replace(this._regexUrlStrip,"");if(k&&l.indexOf(k)===0){l=l.substring(k.length);}return l.charAt(0)==="/"?l:"/"+l;},replace:function(k){return this._queue(k,true);},route:function(l,m){var k=[];this._routes.push({callback:m,keys:k,path:l,regex:this._getRegex(l,k)});return this;},save:function(k){return this._queue(k);},upgrade:function(){if(!this._html5){return false;}var k=this._getHashPath();if(k&&k.charAt(0)==="/"){this.once(d,function(){this.replace(k);});return true;}return false;},_decode:function(k){return decodeURIComponent(k.replace(/\+/g," "));},_dequeue:function(){var k=this,l;if(!YUI.Env.windowLoaded){a.once("load",function(){k._dequeue();});return this;}l=i.shift();return l?l():this;},_dispatch:function(p,m,q){var l=this,k=l.match(p),o,n;l._dispatching=l._dispatched=true;if(!k||!k.length){l._dispatching=false;return l;}o=l._getRequest(p,m,q);n=l._getResponse(o);o.next=function(s){var u,t,r;if(s){a.error(s);}else{if((r=k.shift())){t=r.regex.exec(p);u=typeof r.callback==="string"?l[r.callback]:r.callback;if(t.length===r.keys.length+1){o.params=h.hash(r.keys,t.slice(1));}else{o.params=t.concat();}u.call(l,o,n,o.next);}}};o.next();l._dispatching=false;return l._dequeue();},_getHashPath:function(){return g.getHash().replace(this._regexUrlQuery,"");},_getPath:function(){return(!this._html5&&this._getHashPath())||this.removeRoot(j.pathname);},_getQuery:function(){if(this._html5){return j.search.substring(1);}var l=g.getHash(),k=l.match(this._regexUrlQuery);return l&&k?k[1]:j.search.substring(1);},_getRegex:function(l,k){if(l instanceof RegExp){return l;}if(l==="*"){return/.*/;}l=l.replace(this._regexPathParam,function(n,m,o){k.push(o);return m==="*"?"(.*?)":"([^/]*)";});return new RegExp("^"+l+"$");},_getRequest:function(l,k,m){return{path:l,query:this._parseQuery(this._getQuery()),url:k,src:m};},_getResponse:function(l){var k=function(){return l.next.apply(this,arguments);};k.req=l;return k;},_getRoutes:function(){return this._routes.concat();},_getURL:function(){return j.toString();},_joinURL:function(l){var k=this.get("root");l=this.removeRoot(l);if(l.charAt(0)==="/"){l=l.substring(1);}return k&&k.charAt(k.length-1)==="/"?k+l:k+"/"+l;},_parseQuery:c&&c.parse?c.parse:function(n){var o=this._decode,q=n.split("&"),m=0,l=q.length,k={},p;for(;m<l;++m){p=q[m].split("=");if(p[0]){k[o(p[0])]=o(p[1]||"");}}return k;},_queue:function(){var l=arguments,k=this;i.push(function(){if(k._html5){if(a.UA.ios&&a.UA.ios<5){k._save.apply(k,l);}else{setTimeout(function(){k._save.apply(k,l);},1);}}else{k._dispatching=true;k._save.apply(k,l);}return k;});return !this._dispatching?this._dequeue():this;},_save:function(l,m){var k=typeof l==="string";this._ready=true;if(this._html5){this._history[m?"replace":"add"](null,{url:k?this._joinURL(l):l});}else{k&&(l=this.removeRoot(l));g[m?"replaceHash":"setHash"](l);}return this;},_setRoutes:function(k){this._routes=[];h.each(k,function(l){this.route(l.path,l.callback);},this);return this._routes.concat();},_afterHistoryChange:function(l){var k=this,m=l.src;if(k._ready||m!=="popstate"){k._dispatch(k._getPath(),k._getURL(),m);}},_defReadyFn:function(k){this._ready=true;}},{NAME:"router",ATTRS:{html5:{valueFn:function(){return a.Router.html5;},writeOnce:"initOnly"},root:{value:""},routes:{value:[],getter:"_getRoutes",setter:"_setRoutes"}},html5:a.HistoryBase.html5&&(!a.UA.android||a.UA.android>=3)});a.Controller=a.Router;},"@VERSION@",{optional:["querystring-parse"],requires:["array-extras","base-build","history"]});
+YUI.add("router",function(a){var f=a.HistoryHash,c=a.QueryString,g=a.Array,e=a.config.win,j=e.location,i=j.origin||(j.protocol+"//"+j.host),h=[],d="ready";function b(){b.superclass.constructor.apply(this,arguments);}a.Router=a.extend(b,a.Base,{_regexPathParam:/([:*])([\w\-]+)/g,_regexUrlQuery:/\?([^#]*).*$/,_regexUrlOrigin:/^(?:[^\/#?:]+:\/\/|\/\/)[^\/]*/,initializer:function(l){var k=this;k._html5=k.get("html5");k._routes=[];this._setRoutes(l&&l.routes?l.routes:this.get("routes"));if(k._html5){k._history=new a.HistoryHTML5({force:true});a.after("history:change",k._afterHistoryChange,k);}else{a.on("hashchange",k._afterHistoryChange,e,k);}k.publish(d,{defaultFn:k._defReadyFn,fireOnce:true,preventable:false});k.once("initializedChange",function(){a.once("load",function(){setTimeout(function(){k.fire(d,{dispatched:!!k._dispatched});},20);});});},destructor:function(){if(this._html5){a.detach("history:change",this._afterHistoryChange,this);}else{a.detach("hashchange",this._afterHistoryChange,e);}},dispatch:function(){this.once(d,function(){this._ready=true;if(this._html5&&this.upgrade()){return;}else{this._dispatch(this._getPath(),this._getURL());}});return this;},getPath:function(){return this._getPath();},hasRoute:function(k){if(!this._hasSameOrigin(k)){return false;}return !!this.match(this.removeRoot(k)).length;},match:function(k){return g.filter(this._routes,function(l){return k.search(l.regex)>-1;});},removeRoot:function(l){var k=this.get("root");l=l.replace(this._regexUrlOrigin,"");if(k&&l.indexOf(k)===0){l=l.substring(k.length);}return l.charAt(0)==="/"?l:"/"+l;},replace:function(k){return this._queue(k,true);},route:function(l,m){var k=[];this._routes.push({callback:m,keys:k,path:l,regex:this._getRegex(l,k)});return this;},save:function(k){return this._queue(k);},upgrade:function(){if(!this._html5){return false;}var k=this._getHashPath();if(k&&k.charAt(0)==="/"){this.once(d,function(){this.replace(k);});return true;}return false;},_decode:function(k){return decodeURIComponent(k.replace(/\+/g," "));},_dequeue:function(){var k=this,l;if(!YUI.Env.windowLoaded){a.once("load",function(){k._dequeue();});return this;}l=h.shift();return l?l():this;},_dispatch:function(p,m,q){var l=this,k=l.match(p),o,n;l._dispatching=l._dispatched=true;if(!k||!k.length){l._dispatching=false;return l;}o=l._getRequest(p,m,q);n=l._getResponse(o);o.next=function(s){var u,t,r;if(s){a.error(s);}else{if((r=k.shift())){t=r.regex.exec(p);u=typeof r.callback==="string"?l[r.callback]:r.callback;if(t.length===r.keys.length+1){o.params=g.hash(r.keys,t.slice(1));}else{o.params=t.concat();}u.call(l,o,n,o.next);}}};o.next();l._dispatching=false;return l._dequeue();},_getHashPath:function(){return f.getHash().replace(this._regexUrlQuery,"");},_getOrigin:function(){return i;},_getPath:function(){var k=(!this._html5&&this._getHashPath())||j.pathname;return this.removeRoot(k);},_getQuery:function(){if(this._html5){return j.search.substring(1);}var l=f.getHash(),k=l.match(this._regexUrlQuery);return l&&k?k[1]:j.search.substring(1);},_getRegex:function(l,k){if(l instanceof RegExp){return l;}if(l==="*"){return/.*/;}l=l.replace(this._regexPathParam,function(n,m,o){k.push(o);return m==="*"?"(.*?)":"([^/]*)";});return new RegExp("^"+l+"$");},_getRequest:function(l,k,m){return{path:l,query:this._parseQuery(this._getQuery()),url:k,src:m};},_getResponse:function(l){var k=function(){return l.next.apply(this,arguments);};k.req=l;return k;},_getRoutes:function(){return this._routes.concat();},_getURL:function(){return j.toString();},_hasSameOrigin:function(l){var k=((l&&l.match(this._regexUrlOrigin))||[])[0];if(k&&k.indexOf("//")===0){k=j.protocol+k;}return !k||k===this._getOrigin();},_joinURL:function(l){var k=this.get("root");l=this.removeRoot(l);if(l.charAt(0)==="/"){l=l.substring(1);}return k&&k.charAt(k.length-1)==="/"?k+l:k+"/"+l;},_parseQuery:c&&c.parse?c.parse:function(n){var o=this._decode,q=n.split("&"),m=0,l=q.length,k={},p;for(;m<l;++m){p=q[m].split("=");if(p[0]){k[o(p[0])]=o(p[1]||"");}}return k;},_queue:function(){var l=arguments,k=this;h.push(function(){if(k._html5){if(a.UA.ios&&a.UA.ios<5){k._save.apply(k,l);}else{setTimeout(function(){k._save.apply(k,l);},1);}}else{k._dispatching=true;k._save.apply(k,l);}return k;});return !this._dispatching?this._dequeue():this;},_save:function(l,m){var k=typeof l==="string";if(k&&!this._hasSameOrigin(l)){a.error("Security error: The new URL must be of the same origin as the current URL.");return this;}this._ready=true;if(this._html5){this._history[m?"replace":"add"](null,{url:k?this._joinURL(l):l});}else{k&&(l=this.removeRoot(l));f[m?"replaceHash":"setHash"](l);}return this;},_setRoutes:function(k){this._routes=[];g.each(k,function(l){this.route(l.path,l.callback);},this);return this._routes.concat();},_afterHistoryChange:function(l){var k=this,m=l.src;if(k._ready||m!=="popstate"){k._dispatch(k._getPath(),k._getURL(),m);}},_defReadyFn:function(k){this._ready=true;}},{NAME:"router",ATTRS:{html5:{valueFn:function(){return a.Router.html5;},writeOnce:"initOnly"},root:{value:""},routes:{value:[],getter:"_getRoutes",setter:"_setRoutes"}},html5:a.HistoryBase.html5&&(!a.UA.android||a.UA.android>=3)});a.Controller=a.Router;},"@VERSION@",{optional:["querystring-parse"],requires:["array-extras","base-build","history"]});
View
95 build/router/router.js
@@ -8,12 +8,12 @@ Provides URL-based routing using HTML5 `pushState()` or the location hash.
**/
var HistoryHash = Y.HistoryHash,
- Lang = Y.Lang,
QS = Y.QueryString,
YArray = Y.Array,
win = Y.config.win,
location = win.location,
+ origin = location.origin || (location.protocol + '//' + location.host),
// We have to queue up pushState calls to avoid race conditions, since the
// popstate event doesn't actually provide any info on what URL it's
@@ -111,7 +111,7 @@ Y.Router = Y.extend(Router, Y.Base, {
@type RegExp
@protected
**/
- _regexPathParam: /([:*])([\w-]+)/g,
+ _regexPathParam: /([:*])([\w\-]+)/g,
/**
Regex that matches and captures the query portion of a URL, minus the
@@ -124,15 +124,15 @@ Y.Router = Y.extend(Router, Y.Base, {
_regexUrlQuery: /\?([^#]*).*$/,
/**
- Regex that matches everything before the path portion of an HTTP or HTTPS
- URL. This will be used to strip this part of the URL from a string when we
+ Regex that matches everything before the path portion of a URL (the origin).
+ This will be used to strip this part of the URL from a string when we
only want the path.
- @property _regexUrlStrip
+ @property _regexUrlOrigin
@type RegExp
@protected
**/
- _regexUrlStrip: /^https?:\/\/[^\/]*/i,
+ _regexUrlOrigin: /^(?:[^\/#?:]+:\/\/|\/\/)[^\/]*/,
// -- Lifecycle Methods ----------------------------------------------------
initializer: function (config) {
@@ -220,12 +220,20 @@ Y.Router = Y.extend(Router, Y.Base, {
Returns `true` if this router has at least one route that matches the
specified URL, `false` otherwise.
+ This method enforces the same-origin security constraint on the specified
+ `url`; any URL which is not from the same origin as the current URL will
+ always return `false`.
+
@method hasRoute
@param {String} url URL to match.
@return {Boolean} `true` if there's at least one matching route, `false`
otherwise.
**/
hasRoute: function (url) {
+ if (!this._hasSameOrigin(url)) {
+ return false;
+ }
+
return !!this.match(this.removeRoot(url)).length;
},
@@ -275,7 +283,7 @@ Y.Router = Y.extend(Router, Y.Base, {
// Strip out the non-path part of the URL, if any (e.g.
// "http://foo.com"), so that we're left with just the path.
- url = url.replace(this._regexUrlStrip, '');
+ url = url.replace(this._regexUrlOrigin, '');
if (root && url.indexOf(root) === 0) {
url = url.substring(root.length);
@@ -308,9 +316,9 @@ Y.Router = Y.extend(Router, Y.Base, {
// New URL: http://example.com/
@method replace
- @param {String} [url] URL to set. Should be a relative URL. If this
- router's `root` property is set, this URL must be relative to the
- root URL. If no URL is specified, the page's current URL will be used.
+ @param {String} [url] URL to set. This URL needs to be of the same origin as
+ the current URL. This can be a URL relative to the router's `root`
+ attribute. If no URL is specified, the page's current URL will be used.
@chainable
@see save()
**/
@@ -422,9 +430,9 @@ Y.Router = Y.extend(Router, Y.Base, {
// New URL: http://example.com/
@method save
- @param {String} [url] URL to set. Should be a relative URL. If this
- router's `root` property is set, this URL must be relative to the
- root URL. If no URL is specified, the page's current URL will be used.
+ @param {String} [url] URL to set. This URL needs to be of the same origin as
+ the current URL. This can be a URL relative to the router's `root`
+ attribute. If no URL is specified, the page's current URL will be used.
@chainable
@see replace()
**/
@@ -574,15 +582,29 @@ Y.Router = Y.extend(Router, Y.Base, {
},
/**
- Gets the current route path.
+ Gets the location origin (i.e., protocol, host, and port) as a URL.
+
+ @example
+ http://example.com
+
+ @method _getOrigin
+ @return {String} Location origin (i.e., protocol, host, and port).
+ @protected
+ **/
+ _getOrigin: function () {
+ return origin;
+ },
+
+ /**
+ Gets the current route path, relative to the `root` (if any).
@method _getPath
@return {String} Current route path.
@protected
**/
_getPath: function () {
- return (!this._html5 && this._getHashPath()) ||
- this.removeRoot(location.pathname);
+ var path = (!this._html5 && this._getHashPath()) || location.pathname;
+ return this.removeRoot(path);
},
/**
@@ -660,8 +682,8 @@ Y.Router = Y.extend(Router, Y.Base, {
@protected
**/
_getResponse: function (req) {
- // For backcompat, the response object is a function that calls `next()`
- // on the request object and returns the result.
+ // For backwards compatibility, the response object is a function that
+ // calls `next()` on the request object and returns the result.
var res = function () {
return req.next.apply(this, arguments);
};
@@ -693,15 +715,38 @@ Y.Router = Y.extend(Router, Y.Base, {
},
/**
+ Returns `true` when the specified `url` is from the same origin as the
+ current URL; i.e., the protocol, host, and port of the URLs are the same.
+
+ All host or path relative URLs are of the same origin. A scheme-relative URL
+ is first prefixed with the current scheme before being evaluated.
+
+ @method _hasSameOrigin
+ @param {String} url URL to compare origin with the current URL.
+ @return {Boolean} Whether the URL has the same origin of the current URL.
+ @protected
+ **/
+ _hasSameOrigin: function (url) {
+ var origin = ((url && url.match(this._regexUrlOrigin)) || [])[0];
+
+ // Prepend current scheme to scheme-relative URLs.
+ if (origin && origin.indexOf('//') === 0) {
+ origin = location.protocol + origin;
+ }
+
+ return !origin || origin === this._getOrigin();
+ },
+
+ /**
Joins the `root` URL to the specified _url_, normalizing leading/trailing
`/` characters.
@example
- router.root = '/foo'
+ router.set('root', '/foo');
router._joinURL('bar'); // => '/foo/bar'
router._joinURL('/bar'); // => '/foo/bar'
- router.root = '/foo/'
+ router.set('root', '/foo/');
router._joinURL('bar'); // => '/foo/bar'
router._joinURL('/bar'); // => '/foo/bar'
@@ -800,6 +845,10 @@ Y.Router = Y.extend(Router, Y.Base, {
/**
Saves a history entry using either `pushState()` or the location hash.
+ This method enforces the same-origin security constraint; attempting to save
+ a `url` that is not from the same origin as the current URL will result in
+ an error.
+
@method _save
@param {String} [url] URL for the history entry.
@param {Boolean} [replace=false] If `true`, the current history entry will
@@ -810,6 +859,12 @@ Y.Router = Y.extend(Router, Y.Base, {
_save: function (url, replace) {
var urlIsString = typeof url === 'string';
+ // Perform same-origin check on the specified URL.
+ if (urlIsString && !this._hasSameOrigin(url)) {
+ Y.error('Security error: The new URL must be of the same origin as the current URL.');
+ return this;
+ }
+
// Force _ready to true to ensure that the history change is handled
// even if _save is called before the `ready` event fires.
this._ready = true;
View
95 src/app/js/router.js
@@ -6,12 +6,12 @@ Provides URL-based routing using HTML5 `pushState()` or the location hash.
**/
var HistoryHash = Y.HistoryHash,
- Lang = Y.Lang,
QS = Y.QueryString,
YArray = Y.Array,
win = Y.config.win,
location = win.location,
+ origin = location.origin || (location.protocol + '//' + location.host),
// We have to queue up pushState calls to avoid race conditions, since the
// popstate event doesn't actually provide any info on what URL it's
@@ -109,7 +109,7 @@ Y.Router = Y.extend(Router, Y.Base, {
@type RegExp
@protected
**/
- _regexPathParam: /([:*])([\w-]+)/g,
+ _regexPathParam: /([:*])([\w\-]+)/g,
/**
Regex that matches and captures the query portion of a URL, minus the
@@ -122,15 +122,15 @@ Y.Router = Y.extend(Router, Y.Base, {
_regexUrlQuery: /\?([^#]*).*$/,
/**
- Regex that matches everything before the path portion of an HTTP or HTTPS
- URL. This will be used to strip this part of the URL from a string when we
+ Regex that matches everything before the path portion of a URL (the origin).
+ This will be used to strip this part of the URL from a string when we
only want the path.
- @property _regexUrlStrip
+ @property _regexUrlOrigin
@type RegExp
@protected
**/
- _regexUrlStrip: /^https?:\/\/[^\/]*/i,
+ _regexUrlOrigin: /^(?:[^\/#?:]+:\/\/|\/\/)[^\/]*/,
// -- Lifecycle Methods ----------------------------------------------------
initializer: function (config) {
@@ -218,12 +218,20 @@ Y.Router = Y.extend(Router, Y.Base, {
Returns `true` if this router has at least one route that matches the
specified URL, `false` otherwise.
+ This method enforces the same-origin security constraint on the specified
+ `url`; any URL which is not from the same origin as the current URL will
+ always return `false`.
+
@method hasRoute
@param {String} url URL to match.
@return {Boolean} `true` if there's at least one matching route, `false`
otherwise.
**/
hasRoute: function (url) {
+ if (!this._hasSameOrigin(url)) {
+ return false;
+ }
+
return !!this.match(this.removeRoot(url)).length;
},
@@ -273,7 +281,7 @@ Y.Router = Y.extend(Router, Y.Base, {
// Strip out the non-path part of the URL, if any (e.g.
// "http://foo.com"), so that we're left with just the path.
- url = url.replace(this._regexUrlStrip, '');
+ url = url.replace(this._regexUrlOrigin, '');
if (root && url.indexOf(root) === 0) {
url = url.substring(root.length);
@@ -306,9 +314,9 @@ Y.Router = Y.extend(Router, Y.Base, {
// New URL: http://example.com/
@method replace
- @param {String} [url] URL to set. Should be a relative URL. If this
- router's `root` property is set, this URL must be relative to the
- root URL. If no URL is specified, the page's current URL will be used.
+ @param {String} [url] URL to set. This URL needs to be of the same origin as
+ the current URL. This can be a URL relative to the router's `root`
+ attribute. If no URL is specified, the page's current URL will be used.
@chainable
@see save()
**/
@@ -422,9 +430,9 @@ Y.Router = Y.extend(Router, Y.Base, {
// New URL: http://example.com/
@method save
- @param {String} [url] URL to set. Should be a relative URL. If this
- router's `root` property is set, this URL must be relative to the
- root URL. If no URL is specified, the page's current URL will be used.
+ @param {String} [url] URL to set. This URL needs to be of the same origin as
+ the current URL. This can be a URL relative to the router's `root`
+ attribute. If no URL is specified, the page's current URL will be used.
@chainable
@see replace()
**/
@@ -574,15 +582,29 @@ Y.Router = Y.extend(Router, Y.Base, {
},
/**
- Gets the current route path.
+ Gets the location origin (i.e., protocol, host, and port) as a URL.
+
+ @example
+ http://example.com
+
+ @method _getOrigin
+ @return {String} Location origin (i.e., protocol, host, and port).
+ @protected
+ **/
+ _getOrigin: function () {
+ return origin;
+ },
+
+ /**
+ Gets the current route path, relative to the `root` (if any).
@method _getPath
@return {String} Current route path.
@protected
**/
_getPath: function () {
- return (!this._html5 && this._getHashPath()) ||
- this.removeRoot(location.pathname);
+ var path = (!this._html5 && this._getHashPath()) || location.pathname;
+ return this.removeRoot(path);
},
/**
@@ -660,8 +682,8 @@ Y.Router = Y.extend(Router, Y.Base, {
@protected
**/
_getResponse: function (req) {
- // For backcompat, the response object is a function that calls `next()`
- // on the request object and returns the result.
+ // For backwards compatibility, the response object is a function that
+ // calls `next()` on the request object and returns the result.
var res = function () {
return req.next.apply(this, arguments);
};
@@ -693,15 +715,38 @@ Y.Router = Y.extend(Router, Y.Base, {
},
/**
+ Returns `true` when the specified `url` is from the same origin as the
+ current URL; i.e., the protocol, host, and port of the URLs are the same.
+
+ All host or path relative URLs are of the same origin. A scheme-relative URL
+ is first prefixed with the current scheme before being evaluated.
+
+ @method _hasSameOrigin
+ @param {String} url URL to compare origin with the current URL.
+ @return {Boolean} Whether the URL has the same origin of the current URL.
+ @protected
+ **/
+ _hasSameOrigin: function (url) {
+ var origin = ((url && url.match(this._regexUrlOrigin)) || [])[0];
+
+ // Prepend current scheme to scheme-relative URLs.
+ if (origin && origin.indexOf('//') === 0) {
+ origin = location.protocol + origin;
+ }
+
+ return !origin || origin === this._getOrigin();
+ },
+
+ /**
Joins the `root` URL to the specified _url_, normalizing leading/trailing
`/` characters.
@example
- router.root = '/foo'
+ router.set('root', '/foo');
router._joinURL('bar'); // => '/foo/bar'
router._joinURL('/bar'); // => '/foo/bar'
- router.root = '/foo/'
+ router.set('root', '/foo/');
router._joinURL('bar'); // => '/foo/bar'
router._joinURL('/bar'); // => '/foo/bar'
@@ -800,6 +845,10 @@ Y.Router = Y.extend(Router, Y.Base, {
/**
Saves a history entry using either `pushState()` or the location hash.
+ This method enforces the same-origin security constraint; attempting to save
+ a `url` that is not from the same origin as the current URL will result in
+ an error.
+
@method _save
@param {String} [url] URL for the history entry.
@param {Boolean} [replace=false] If `true`, the current history entry will
@@ -810,6 +859,12 @@ Y.Router = Y.extend(Router, Y.Base, {
_save: function (url, replace) {
var urlIsString = typeof url === 'string';
+ // Perform same-origin check on the specified URL.
+ if (urlIsString && !this._hasSameOrigin(url)) {
+ Y.error('Security error: The new URL must be of the same origin as the current URL.');
+ return this;
+ }
+
// Force _ready to true to ensure that the history change is handled
// even if _save is called before the `ready` event fires.
this._ready = true;
View
119 src/app/tests/app-test.js
@@ -6,6 +6,8 @@ var ArrayAssert = Y.ArrayAssert,
html5 = Y.Router.html5,
+ win = Y.config.win,
+
routerSuite,
modelSuite,
modelListSuite,
@@ -1808,9 +1810,20 @@ routerSuite.add(new Y.Test.Case({
routerSuite.add(new Y.Test.Case({
name: 'Methods',
+ startUp: function () {
+ this.errorFn = Y.config.errorFn;
+ this.throwFail = Y.config.throwFail;
+ },
+
tearDown: function () {
this.router && this.router.destroy();
delete this.router;
+
+ Y.config.errorFn = this.errorFn;
+ delete this.errorFn;
+
+ Y.config.throwFail = this.throwFail;
+ delete this.throwFail;
},
'route() should add a route': function () {
@@ -1835,7 +1848,7 @@ routerSuite.add(new Y.Test.Case({
var router = this.router = new Y.Router(),
routes;
- function one () {}
+ function one() {}
function two() {}
function three() {}
@@ -1851,8 +1864,7 @@ routerSuite.add(new Y.Test.Case({
},
'hasRoute() should return `true` if one or more routes match the given path': function () {
- var router = this.router = new Y.Router(),
- routes;
+ var router = this.router = new Y.Router();
function noop () {}
@@ -1867,7 +1879,8 @@ routerSuite.add(new Y.Test.Case({
'hasRoute() should support full URLs': function () {
var router = this.router = new Y.Router(),
- routes;
+ loc = win && win.location,
+ origin = loc ? (loc.origin || (loc.protocol + '//' + loc.host)) : '';
function noop () {}
@@ -1875,9 +1888,27 @@ routerSuite.add(new Y.Test.Case({
router.route(/foo/, noop);
router.route('/bar', noop);
- Assert.isTrue(router.hasRoute('http://example.com/foo'));
- Assert.isTrue(router.hasRoute('https://example.com/bar'));
- Assert.isFalse(router.hasRoute('http://example.com/baz/quux'));
+ Assert.isTrue(router.hasRoute(origin + '/foo'));
+ Assert.isTrue(router.hasRoute(origin + '/bar'));
+ Assert.isFalse(router.hasRoute(origin + '/baz/quux'));
+
+ // Scheme-relative URL.
+ Assert.isTrue(router.hasRoute('//' + loc.host + '/foo'));
+ },
+
+ 'hasRoute() should always return `false` for URLs with different origins': function () {
+ var router = this.router = new Y.Router(),
+ origin = 'http://something.really.random.com';
+
+ function noop () {}
+
+ router.route('/:foo', noop);
+ router.route(/foo/, noop);
+ router.route('/bar', noop);
+
+ Assert.isFalse(router.hasRoute(origin + '/foo'));
+ Assert.isFalse(router.hasRoute(origin + '/bar'));
+ Assert.isFalse(router.hasRoute(origin + '/baz/quux'));
},
'dispatch() should dispatch to the first route that matches the current URL': function () {
@@ -1934,7 +1965,7 @@ routerSuite.add(new Y.Test.Case({
Assert.areSame('/foo/bar', router.removeRoot('/foo/bar'));
},
- 'removeRoot() should strip the "http://foo.com" portion of the URL, if any': function () {
+ 'removeRoot() should strip the origin ("http://foo.com") portion of the URL, if any': function () {
var router = this.router = new Y.Router();
Assert.areSame('/foo/bar', router.removeRoot('http://example.com/foo/bar'));
@@ -1942,6 +1973,8 @@ routerSuite.add(new Y.Test.Case({
Assert.areSame('/foo/bar', router.removeRoot('http://user:pass@example.com/foo/bar'));
Assert.areSame('/foo/bar', router.removeRoot('http://example.com:8080/foo/bar'));
Assert.areSame('/foo/bar', router.removeRoot('http://user:pass@example.com:8080/foo/bar'));
+ Assert.areSame('/foo/bar', router.removeRoot('file:///foo/bar'));
+ Assert.areSame('/foo/bar', router.removeRoot('/foo/bar'));
router.set('root', '/foo');
Assert.areSame('/bar', router.removeRoot('http://example.com/foo/bar'));
@@ -1949,10 +1982,12 @@ routerSuite.add(new Y.Test.Case({
Assert.areSame('/bar', router.removeRoot('http://user:pass@example.com/foo/bar'));
Assert.areSame('/bar', router.removeRoot('http://example.com:8080/foo/bar'));
Assert.areSame('/bar', router.removeRoot('http://user:pass@example.com:8080/foo/bar'));
+ Assert.areSame('/bar', router.removeRoot('file:///foo/bar'));
+ Assert.areSame('/bar', router.removeRoot('/foo/bar'));
},
'replace() should replace the current history entry': function () {
- var test = this,
+ var test = this,
router = this.router = new Y.Router();
router.route('/replace', function (req) {
@@ -1972,7 +2007,7 @@ routerSuite.add(new Y.Test.Case({
},
'save() should create a new history entry': function () {
- var test = this,
+ var test = this,
router = this.router = new Y.Router();
router.route('/save', function (req) {
@@ -2023,16 +2058,78 @@ routerSuite.add(new Y.Test.Case({
this.wait(2000);
},
- '_joinURL() should normalize / separators': function () {
+ 'replace() should error when the URL is not from the same origin': function () {
+ var router = this.router = new Y.Router(),
+ origin = 'http://something.really.random.com',
+ test = this;
+
+ // We don't want the uncaught error line noise because we expect an
+ // error to be thrown, and it won't be caught because `save()` is async.
+ Y.config.throwFail = false;
+ Y.config.errorFn = function (e) {
+ test.resume(function () {
+ Assert.areSame(e, 'Security error: The new URL must be of the same origin as the current URL.');
+ });
+ };
+
+ router.route('/foo', function () {
+ test.resume(function () {
+ Assert.fail('Should not route when URL has different origin.');
+ });
+ });
+
+ // Wrapped in a setTimeout to make the async test work on iOS<5, which
+ // performs this action synchronously.
+ setTimeout(function () {
+ router.replace(origin + '/foo');
+ }, 1);
+
+ this.wait(500);
+ },
+
+ 'save() should error when the URL is not from the same origin': function () {
+ var router = this.router = new Y.Router(),
+ origin = 'http://something.really.random.com',
+ test = this;
+
+ // We don't want the uncaught error line noise because we expect an
+ // error to be thrown, and it won't be caught because `save()` is async.
+ Y.config.throwFail = false;
+ Y.config.errorFn = function (e) {
+ test.resume(function () {
+ Assert.areSame(e, 'Security error: The new URL must be of the same origin as the current URL.');
+ });
+ };
+
+ router.route('/foo', function () {
+ test.resume(function () {
+ Assert.fail('Should not route when URL has different origin.');
+ });
+ });
+
+ // Wrapped in a setTimeout to make the async test work on iOS<5, which
+ // performs this action synchronously.
+ setTimeout(function () {
+ router.save(origin + '/foo');
+ }, 1);
+
+ this.wait(500);
+ },
+
+ '_joinURL() should normalize "/" separators': function () {
var router = this.router = new Y.Router();
router.set('root', '/foo');
Assert.areSame('/foo/bar', router._joinURL('bar'));
Assert.areSame('/foo/bar', router._joinURL('/bar'));
+ Assert.areSame('/foo/bar', router._joinURL('/foo/bar'));
+ Assert.areSame('/foo/foo/bar', router._joinURL('foo/bar'));
router.set('root', '/foo/');
Assert.areSame('/foo/bar', router._joinURL('bar'));
Assert.areSame('/foo/bar', router._joinURL('/bar'));
+ Assert.areSame('/foo/bar', router._joinURL('/foo/bar'));
+ Assert.areSame('/foo/foo/bar', router._joinURL('foo/bar'));
},
'_dispatch() should pass `src` through to request object passed to route handlers': function () {
View
398 src/pjax/js/pjax-base.js
@@ -1,20 +1,91 @@
-var win = Y.config.win,
-
- CLASS_PJAX = Y.ClassNameManager.getClassName('pjax'),
+/**
+`Y.Router` extension that provides the core plumbing for enhanced navigation
+implemented using the pjax technique (HTML5 `pushState` + Ajax).
+
+@submodule pjax-base
+@since 3.5.0
+**/
+
+var win = Y.config.win,
+ location = win.location,
+
+ Lang = Y.Lang,
+
+ // The CSS class name used to filter link clicks from only the links which
+ // the pjax enhanced navigation should be used.
+ CLASS_PJAX = Y.ClassNameManager.getClassName('pjax'),
+
+ /**
+ Fired when navigating to the specified URL is being enhanced by the router.
+
+ When the `navigate()` method is called or a "pjax" link is clicked, this
+ event will be fired if: the browser is HTML5-history enabled, and the router
+ has a route-handler for the specified URL.
+
+ This is a useful event to listen to for adding a visual loading indicator
+ while the route handlers are busy handling the URL change.
+
+ @event navigate
+ @param {String} url The URL that the router will dispatch to its route
+ handlers in order to fulfill the enhanced navigation "request".
+ @param {Event} [originEvent] The event that caused the navigation, usually
+ this would be a click event from a "pjax" anchor element.
+ @param {Boolean} [replace] Whether or not the current history entry will be
+ replaced, or a new entry will be created. Will default to `true` if the
+ specified `url` is the same as the current URL.
+ @param {Boolean} [force=false] Whether the enhanced navigation should occur
+ even in browsers without HTML5 history.
+ **/
EVT_NAVIGATE = 'navigate';
-// PjaxBase is a mixin for Router.
+/**
+`Y.Router` extension that provides the core plumbing for enhanced navigation
+implemented using the pjax technique (HTML5 `pushState` + Ajax).
+
+This makes it easy to enhance the navigation between the URLs of an application
+in HTML5 history capable browsers by delegating to the router to fulfill the
+"request" and seamlessly falling-back to using standard full-page reloads in
+older, less-capable browsers.
+
+@class PjaxBase
+@extensionfor Router
+**/
function PjaxBase() {}
PjaxBase.prototype = {
- // -- Properties -----------------------------------------------------------
- _resolved: {},
- _regexUrl: /^((?:([^:]+):(?:\/\/)?|\/\/)[^\/]*)?([^?#]*)(.*)$/i,
+ // -- Protected Properties -------------------------------------------------
+
+ /**
+ Holds the delegated pjax-link click handler.
+
+ @property _pjaxEvents
+ @type EventHandle
+ @default undefined
+ @protected
+ **/
+
+ /**
+ Regex used to break-up a URL string around the URL's path.
+
+ Subpattern captures:
+
+ 1. Origin, everything before the URL's path-part.
+ 2. The URL's path-part.
+ 3. Suffix, everything after the URL's path-part.
+
+ @property _regexURL
+ @type RegExp
+ @protected
+ **/
+ _regexURL: /^((?:[^\/#?:]+:\/\/|\/\/)[^\/]*)?([^?#]*)(.*)$/,
// -- Lifecycle Methods ----------------------------------------------------
initializer: function () {
this.publish(EVT_NAVIGATE, {defaultFn: this._defNavigateFn});
+ // Pjax is all about progressively-enhancing the navigation between
+ // "pages", so by default we only want to handle and route link clicks
+ // in HTML5 `pushState`-compatible browsers.
if (this.get('html5')) {
this._pjaxBindUI();
}
@@ -24,138 +95,349 @@ PjaxBase.prototype = {
this._pjaxEvents && this._pjaxEvents.detach();
},
- // -- Public Prototype Methods ---------------------------------------------
+ // -- Public Methods -------------------------------------------------------
+
+ /**
+ Navigates to the specified URL if there is a router-handler that matches. In
+ browsers capable of using HTML5 history, the navigation will be enhanced by
+ firing the `navigate` and having the router handle the "request". Non-HTML5
+ browsers will navigate to the new URL via manipulation of `window.location`.
+
+ When there is a route-handler for the specified URL and it is being
+ navigated to, this method will return `true`, otherwise it will return
+ `false`.
+
+ **Note:** The specified URL _must_ be of the same origin as the current URL,
+ otherwise an error will be logged and the navigation will not be performed.
+ This is intended as both a security constraint and an purposely imposed
+ limitation as it does not make sense to tell the router to navigate to some
+ URL on a different scheme, host, or port.
+
+ @method navigate
+ @param {String} url The URL to navigate to. This must be of the same-origin
+ as the current URL.
+ @param {Object} [options] Additional options to configure the navigation,
+ these are mixed into the `navigate` event facade.
+ @param {Boolean} [options.replace] Whether or not the current history
+ entry will be replaced, or a new entry will be created. Will default
+ to `true` if the specified `url` is the same as the current URL.
+ @param {Boolean} [options.force=false] Whether the enhanced navigation
+ should occur even in browsers without HTML5 history.
+ @return {Boolean} `true` if the URL was navigated to, `false` otherwise.
+ **/
navigate: function (url, options) {
- options || (options = {});
- options.url = url;
+ // The `_navigate()` method expects fully-resolved URLs.
+ url = this._resolveURL(url);
- this.fire(EVT_NAVIGATE, options);
+ if (this._navigate(url, options)) {
+ return true;
+ }
+
+ if (!this._hasSameOrigin(url)) {
+ Y.error('Security error: The new URL must be of the same origin as the current URL.');
+ }
+
+ return false;
},
- // -- Protected Prototype Methods ------------------------------------------
+ // -- Protected Methods ----------------------------------------------------
+
+ /**
+ Returns the current path root after popping-off the last path segment making
+ it useful for resolving other URL paths against.
+
+ The path root will always begin and end with a '/'.
+
+ @method _getRoot
+ @return {String} The URL's path root.
+ @protected
+ **/
_getRoot: function () {
- var segments = (win && win.location.pathname.split('/')) || [];
+ var slash = '/',
+ path = location.pathname,
+ segments;
+
+ if (path.charAt(path.length - 1) === slash) {
+ return path;
+ }
+
+ segments = path.split(slash);
segments.pop();
- return segments.join('/');
+
+ return segments.join(slash) + slash;
},
+ /**
+ Navigates to the specified URL if there is a router-handler that matches. In
+ browsers capable of using HTML5 history, the navigation will be enhanced by
+ firing the `navigate` and having the router handle the "request". Non-HTML5
+ browsers will navigate to the new URL via manipulation of `window.location`.
+
+ When there is a route-handler for the specified URL and it is being
+ navigated to, this method will return `true`, otherwise it will return
+ `false`.
+
+ The enhanced navigation flow can be forced causing all navigation to route
+ through the router; but this is not advised as it can will produce less
+ desirable hash-based URLs in non-HTML5 browsers.
+
+ @method _navigate
+ @param {String} url The fully-resolved URL that the router should dispatch
+ to its route handlers to fulfill the enhanced navigation "request", or use
+ to update `window.location` in non-HTML5 history capable browsers.
+ @param {Object} [options] Additional options to configure the navigation,
+ these are mixed into the `navigate` event facade.
+ @param {Boolean} [options.replace] Whether or not the current history
+ entry will be replaced, or a new entry will be created. Will default
+ to `true` if the specified `url` is the same as the current URL.
+ @param {Boolean} [options.force=false] Whether the enhanced navigation
+ should occur even in browsers without HTML5 history.
+ @protected
+ **/
+ _navigate: function (url, options) {
+ // Navigation can only be enhanced if there is a route-handler.
+ if (!this.hasRoute(url)) {
+ return false;
+ }
+
+ options || (options = {});
+ options.url = url;
+
+ // When navigating to the same URL as the current URL, behave like a
+ // browser and replace the history entry instead of creating a new one.
+ Lang.isValue(options.replace) || (options.replace = url === this._getURL());
+
+ // The `navigate` event will only fire and therefore enhance the
+ // navigation to the new URL in HTML5 history enabled browsers or when
+ // forced. Otherwise it will fallback to assigning or replacing the URL
+ // on `window.location`.
+ if (this.get('html5') || options.force) {
+ this.fire(EVT_NAVIGATE, options);
+ } else {
+ if (options.replace) {
+ location.replace(url);
+ } else {
+ win.location = url;
+ }
+ }
+
+ return true;
+ },
+
+ /**
+ Returns a normalized path, riding it of any '..' segments and properly
+ handling leading and trailing '/'s.
+
+ @method _normalizePath
+ @param {String} path The URL path to normalize.
+ @return {String} The normalized path.
+ @protected
+ **/
_normalizePath: function (path) {
var dots = '..',
slash = '/',
- i, len, normalized, parts, part, stack;
+ i, len, normalized, segments, segment, stack;
if (!path) {
- return path;
+ return slash;
}
- parts = path.split(slash);
- stack = [];
+ segments = path.split(slash);
+ stack = [];
- for (i = 0, len = parts.length; i < len; ++i) {
- part = parts[i];
+ for (i = 0, len = segments.length; i < len; ++i) {
+ segment = segments[i];
- if (part === dots) {
+ if (segment === dots) {
stack.pop();
- } else if (part) {
- stack.push(part);
+ } else if (segment) {
+ stack.push(segment);
}
}
- normalized = stack.join(slash);
+ normalized = slash + stack.join(slash);
- // Append a slash if necessary.
- if (path.charAt(path.length - 1) === slash) {
+ // Append trailing slash if necessary.
+ if (normalized !== slash && path.charAt(path.length - 1) === slash) {
normalized += slash;
}
return normalized;
},
+ /**
+ Binds the delegation of link-click events that match the `linkSelector` to
+ the `_onLinkClick()` handler.
+
+ By default this method will only be called if the browser is capable of
+ using HTML5 history.
+
+ @method _pjaxBindUI
+ @protected
+ **/
_pjaxBindUI: function () {
+ // Only bind link if we haven't already.
if (!this._pjaxEvents) {
this._pjaxEvents = Y.one('body').delegate('click',
this._onLinkClick, this.get('linkSelector'), this);
}
},
- _resolvePath: function (path, root) {
- root || (root = this._getRoot());
+ /**
+ Returns the normalized result of resolving the `path` against the current
+ path.
+
+ A host-relative `path` (one that begins with '/') is assumed to be resolved
+ and is returned as is. Falsy values for `path` will return just the current
+ path.
+ @method _resolvePath
+ @param {String} path The URL path to resolve.
+ @return {String} The resolved path.
+ @protected
+ **/
+ _resolvePath: function (path) {
if (!path) {
- return root;
+ return this._getPath();
}
- // Path is host relative.
+ // Path is host-relative and assumed to be resolved and normalized,
+ // meaning silly paths like: '/foo/../bar/' will be returned as-is.
if (path.charAt(0) === '/') {
return path;
}
- return this._normalizePath(root + '/' + path);
+ return this._normalizePath(this._getRoot() + path);
},
- _resolveUrl: function (url) {
- var self = this,
- root = self._getRoot(),
- resolved, resolvedUrl;
-
- resolved = self._resolved[root] || (self._resolved[root] = {});
- resolvedUrl = resolved[url];
-
- if (resolvedUrl) {
- return resolvedUrl;
+ /**
+ Resolves the specified URL against the current URL.
+
+ This method resolves URLs like a browser does and will always return an
+ absolute URL. When the specified URL is already absolute, it is assumed to
+ be fully resolved and is simply returned as is. Scheme-relative URLs are
+ prefixed with the current protocol. Relative URLs are giving the current
+ URL's origin and are resolved and normalized against the current path-root.
+
+ @method _resolveURL
+ @param {String} url The URL to resolve.
+ @return {String} The resolved URL.
+ @protected
+ **/
+ _resolveURL: function (url) {
+ var parts = url && url.match(this._regexURL),
+ origin, path, suffix;
+
+ if (!parts) {
+ return this._getURL();
}
- function resolve(match, prefix, scheme, path, suffix) {
- if (scheme && scheme.toLowerCase().indexOf('http') !== 0) {
- return match;
+ origin = parts[1];
+ path = parts[2];
+ suffix = parts[3];
+
+ // Absolute and scheme-relative URLs are assumed to be fully-resolved.
+ if (origin) {
+ // Prepend the current scheme for scheme-relative URLs.
+ if (origin.indexOf('//') === 0) {
+ origin = location.protocol + origin;
}
- return (prefix || '') + self._resolvePath(path, root) + (suffix || '');
+ return origin + (path || '/') + (suffix + '');
}
- // Cache resolved URL.
- resolvedUrl = resolved[url] = url.replace(self._regexUrl, resolve);
-
- return resolvedUrl;
+ return this._getOrigin() + this._resolvePath(path) + (suffix || '');
},
// -- Protected Event Handlers ---------------------------------------------
+
+ /**
+ Default handler for the `navigate` event.
+
+ Adds a new history entry or replaces the current entry for the specified URL
+ and will scroll the page to the top if configured to do so.
+
+ @method _defNavigateFn
+ @param {EventFacade} e
+ @protected
+ **/
_defNavigateFn: function (e) {
- this.save(this._resolveUrl(e.url));
+ this[e.replace ? 'replace' : 'save'](e.url);
- if (this.get('scrollToTop') && Y.config.win) {
+ if (win && this.get('scrollToTop')) {
// Scroll to the top of the page. The timeout ensures that the
// scroll happens after navigation begins, so that the current
// scroll position will be restored if the user clicks the back
// button.
setTimeout(function () {
- Y.config.win.scroll(0, 0);
+ win.scroll(0, 0);
}, 1);
}
},
+ /**
+ Handler for the delegated link-click events which match the `linkSelector`.
+
+ This will attempt to enhance the navigation to the link element's `href` by
+ passing the URL to the `_navigate()` method. When the navigation is being
+ enhanced, the default action is prevented.
+
+ If the user clicks a link with the middle/right mouse buttons, or is holding
+ down the Ctrl or Command keys, this method's behavior is not applied and
+ allows the native behavior to occur. Similarly, if the router is not capable
+ or handling the URL because no route-handlers match, the link click will
+ behave natively.
+
+ @method _onLinkClick
+ @param {EventFacade} e
+ @protected
+ **/
_onLinkClick: function (e) {
- var url = this._resolveUrl(e.currentTarget.get('href'));
+ var url;
// Allow the native behavior on middle/right-click, or when Ctrl or
// Command are pressed.
if (e.button !== 1 || e.ctrlKey || e.metaKey) { return; }
- // Do nothing if there's no matching route for this URL.
- if (!this.hasRoute(url)) { return; }
+ // All browsers fully resolve an anchor's `href` property.
+ url = e.currentTarget.get('href');
- e.preventDefault();
-
- this.navigate(url, {originEvent: e});
+ // Try and navigate to the URL via the router, and prevent the default
+ // link-click action if we do.
+ url && this._navigate(url, {originEvent: e}) && e.preventDefault();
}
};
PjaxBase.ATTRS = {
+ /**
+ This selector is used so only the click events who's links match will have
+ the enhanced navigation behavior applied.
+
+ When a link being clicked on matches this selector, the browsers default of
+ navigating to the URL by doing a full-page reload will be prevented;
+ instead, navigating to the URL will be enhanced by have the router fulfill
+ the "request" by updating the URL and content of the page.
+
+ @attribute linkSelector
+ @type String|Function
+ @default `'a.pjax'`
+ @initOnly
+ **/
linkSelector: {
value : 'a.' + CLASS_PJAX,
writeOnce: 'initOnly'
},
+ /**
+ Whether the page should be scrolled to the top after navigating to a URL.
+
+ When the user clicks the browser's back button, the previous scroll-position
+ will be maintained.
+
+ @attribute scrollToTop
+ @type Boolean
+ @default `true`
+ **/
scrollToTop: {
value: true
}
View
3  src/pjax/tests/pjax-test.js
@@ -121,7 +121,8 @@ suite.add(new Y.Test.Case({
_should: {
ignore: {
'`error` event should fire on Ajax failure': disableXHR || !html5,