diff --git a/src/index.js b/src/index.js index ac35c74e..c69a6564 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ import { cloneElement, h, Component } from 'preact'; -import { exec, pathRankSort } from './util'; +import { exec, rankChild } from './util'; let customHistory = null; @@ -21,20 +21,38 @@ function setUrl(url, type='push') { } +function getCurrentLocation() { + return (customHistory && customHistory.location) || + (customHistory && customHistory.getCurrentLocation && customHistory.getCurrentLocation()) || + (typeof location!=='undefined' ? location : EMPTY); +} + function getCurrentUrl() { - let url; - if (customHistory && customHistory.location) { - url = customHistory.location; - } - else if (customHistory && customHistory.getCurrentLocation) { - url = customHistory.getCurrentLocation(); - } - else { - url = typeof location!=='undefined' ? location : EMPTY; - } + let url = getCurrentLocation(); return `${url.pathname || ''}${url.search || ''}`; } +const a = typeof document!=='undefined' && document.createElement('a'); + +// Based on https://tools.ietf.org/html/rfc3986#appendix-B +const uriRegex = new RegExp('^([^:/?#]+:)?(?://([^/?#]*))?([^?#]*)((?:\\?[^#]*)?)((?:#.*)?)'); + +/* Resolve URL relative to current location */ +function resolve(url) { + let current = getCurrentLocation(); + if (a) { + a.setAttribute('href', url); + url = a.href; + } + let [,protocol,host,pathname,search] = uriRegex.exec(url); + if ( + (current.protocol && protocol !== current.protocol) || + (current.host && host !== current.host) + ) { + return; + } + return `${pathname}${search}`; +} function route(url, replace=false) { if (typeof url!=='string' && url.url) { @@ -42,6 +60,9 @@ function route(url, replace=false) { url = url.url; } + url = resolve(url); + if (!url) return; + // only push URL into history if we can handle it if (canRoute(url)) { setUrl(url, replace ? 'replace' : 'push'); @@ -53,22 +74,13 @@ function route(url, replace=false) { /** Check if the given URL can be handled by any router instances. */ function canRoute(url) { - for (let i=ROUTERS.length; i--; ) { - if (ROUTERS[i].canRoute(url)) return true; - } - return false; + return ROUTERS.some(router => router.canRoute(url)); } /** Tell all router instances to handle the given URL. */ function routeTo(url) { - let didRoute = false; - for (let i=0; i (router.routeTo(url) === true || didRoute), false); } @@ -79,8 +91,8 @@ function routeFromLink(node) { let href = node.getAttribute('href'), target = node.getAttribute('target'); - // ignore links with targets and non-path URLs - if (!href || !href.match(/^\//g) || (target && !target.match(/^_?self$/i))) return; + // ignore links with targets + if (!href || (target && !target.match(/^_?self$/i))) return; // attempt to route, if no match simply cede control to browser return route(href); @@ -158,13 +170,12 @@ class Router extends Component { } shouldComponentUpdate(props) { - if (props.static!==true) return true; - return props.url!==this.props.url || props.onChange!==this.props.onChange; + return props.static!==true || props.url!==this.props.url || props.onChange!==this.props.onChange; } /** Check if the given URL can be matched against any children */ canRoute(url) { - return this.getMatchingChildren(this.props.children, url, false).length > 0; + return this.props.children.some(({ attributes=EMPTY }) => !!exec(url, attributes.path, attributes)); } /** Re-render children with a new URL to match against. */ @@ -207,24 +218,32 @@ class Router extends Component { } getMatchingChildren(children, url, invoke) { - return children.slice().sort(pathRankSort).map( vnode => { - let path = vnode.attributes.path, - matches = exec(url, path, vnode.attributes); - if (matches) { - if (invoke!==false) { - let newProps = { url, matches }; - // copy matches onto props - for (let i in matches) { - if (matches.hasOwnProperty(i)) { - newProps[i] = matches[i]; - } - } - return cloneElement(vnode, newProps); - } - return vnode; - } - return false; - }).filter(Boolean); + return children + .filter(({ attributes }) => !!attributes) + .map((child, index) => ({ child, index, rank: rankChild(child) })) + .sort((a, b) => ( + (a.rank < b.rank) ? 1 : + (a.rank > b.rank) ? -1 : + (a.index - b.index) + )) + .map( vnode => { + let path = vnode.attributes.path, + matches = exec(url, path, vnode.attributes); + if (matches) { + if (invoke!==false) { + let newProps = { url, matches }; + // copy matches onto props + for (let i in matches) { + if (matches.hasOwnProperty(i)) { + newProps[i] = matches[i]; + } + } + return cloneElement(vnode, newProps); + } + return vnode; + } + return false; + }).filter(Boolean); } render({ children, onChange }, { url }) { diff --git a/src/util.js b/src/util.js index 7e433f1e..4b5e79ad 100644 --- a/src/util.js +++ b/src/util.js @@ -2,24 +2,16 @@ const EMPTY = {}; export function exec(url, route, opts=EMPTY) { - let reg = /(?:\?([^#]*))?(#.*)?$/, - c = url.match(reg), + let reg = /^([^?]*)(?:\?([^#]*))?(#.*)?$/, + [, pathname, search] = (url.match(reg) || []), matches = {}, ret; - if (c && c[1]) { - let p = c[1].split('&'); - for (let i=0; i { + let [name, ...value] = parameter.split('='); + queryParams[decodeURIComponent(name)] = decodeURIComponent(value.join('=')); + }); + matches = { + ...queryParams, + ...matches + }; + } return matches; } @@ -47,16 +51,28 @@ export function pathRankSort(a, b) { bAttr = b.attributes || EMPTY; if (aAttr.default) return 1; if (bAttr.default) return -1; - let diff = rank(aAttr.path) - rank(bAttr.path); - return diff || (aAttr.path.length - bAttr.path.length); + let aRank = rank(aAttr.path), + bRank = rank(bAttr.path); + return (aRank < bRank) ? 1 : + (aRank == bRank) ? 0 : + -1; } export function segmentize(url) { return strip(url).split('/'); } -export function rank(url) { - return (strip(url).match(/\/+/g) || '').length; +export const rankSegment = (segment) => { + let [, isParam, , flag] = /^(:?)(.*?)([*+?]?)$/.exec(segment); + return isParam ? ('0*+?'.indexOf(flag) || 4) : 5; +}; + +export const rank = (path) => ( + segmentize(path).map(rankSegment).join('') +); + +export function rankChild({ attributes=EMPTY }) { + return attributes.default ? '0' : rank(attributes.path); } export function strip(url) { diff --git a/test/util.js b/test/util.js index 9942a70e..22802c5b 100644 --- a/test/util.js +++ b/test/util.js @@ -19,12 +19,13 @@ describe('util', () => { }); describe('rank', () => { - it('should return number of path segments', () => { - expect(rank('')).to.equal(0); - expect(rank('/')).to.equal(0); - expect(rank('//')).to.equal(0); - expect(rank('a/b/c')).to.equal(2); - expect(rank('/a/b/c/')).to.equal(2); + it('should return rank of path segments', () => { + expect(rank('')).to.eql('5'); + expect(rank('/')).to.eql('5'); + expect(rank('//')).to.eql('5'); + expect(rank('a/b/c')).to.eql('555'); + expect(rank('/a/b/c/')).to.eql('555'); + expect(rank('/:a/b?/:c?/:d*/:e+')).to.eql('45312'); }); }); @@ -39,13 +40,13 @@ describe('util', () => { }); describe('pathRankSort', () => { - it('should sort by segment count', () => { + it('should sort by highest rank first', () => { let paths = arr => arr.map( path => ({attributes:{path}}) ); expect( - paths(['/a/b/','/a/b','/','b']).sort(pathRankSort) + paths(['/:a*','/a','/:a+','/:a?','/a/:b*']).sort(pathRankSort) ).to.eql( - paths(['/','b','/a/b','/a/b/']) + paths(['/a/:b*','/a','/:a?','/:a+','/:a*']) ); }); @@ -59,7 +60,7 @@ describe('util', () => { expect( p.sort(pathRankSort) ).to.eql( - paths(['/','b','/a/b','/a/b/']).concat(defaultPath) + paths(['/a/b/','/a/b','/','b']).concat(defaultPath) ); }); }); @@ -101,5 +102,10 @@ describe('util', () => { expect(exec('/a/b', '/:foo+')).to.eql({ foo:'a/b' }); expect(exec('/a/b/c', '/:foo+')).to.eql({ foo:'a/b/c' }); }); + + it('should handle query-string', () => { + expect(exec('/?foo=bar', '/')).to.eql({ foo: 'bar' }); + expect(exec('/a?foo=bar', '/:foo')).to.eql({ foo: 'a' }); + }); }); });