Skip to content

Handle relative URLs #138

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 11 commits into from
109 changes: 64 additions & 45 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cloneElement, h, Component } from 'preact';
import { exec, pathRankSort } from './util';
import { exec, rankChild } from './util';

let customHistory = null;

Expand All @@ -21,27 +21,48 @@ function setUrl(url, type='push') {
}


function getCurrentLocation() {
return (customHistory && customHistory.location) ||
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementation is good, but if it's only ever called from getCurrentUrl(), let's just inline it there.

Copy link
Contributor Author

@ashsearle ashsearle Feb 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's also called from the new resolve method used by route.

I was wondering whether to tweak resolve to take a base parameter as well, then move it to util.js and add test-cases.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depends - there was talk about moving customHistory to be a property of Router instances so that they had proper instance separation instead of the singleton stuff. Might be better to go that route rather than making it more singelton-ey? Different PR I guess though.

(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) {
replace = url.replace;
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');
Expand All @@ -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<ROUTERS.length; i++) {
if (ROUTERS[i].routeTo(url)===true) {
didRoute = true;
}
}
return didRoute;
return ROUTERS.reduce((didRoute, router) => (router.routeTo(url) === true || didRoute), false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm - did these get added from another PR? I don't remember them being here before. .some() and .reduce() aren't supported in some of the browsers we support, and add to the overall size.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sort of: I cocked-up putting pull request #136 on master, and brought them in here with poor git skills. I can resubmit.

Please check pull request #136 again: these methods (some and reduce) have been supported since way back in IE9, so should be fine... No polyfill bloat required.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know, I often forget these things now haha. Still curious about the filesize hit for all those function definitions, but I guess this repo isn't exactly aiming for extreme small size.

I'll pull this down this weekend and play around with it - I feel horrible about how long this PR and the nested routers PR have been sitting here, really want to get things merged since you guys did so much work on them!

}


Expand All @@ -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);
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 }) {
Expand Down
54 changes: 35 additions & 19 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,47 @@
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<p.length; i++) {
let r = p[i].split('=');
matches[decodeURIComponent(r[0])] = decodeURIComponent(r.slice(1).join('='));
}
}
url = segmentize(url.replace(reg, ''));
url = segmentize(pathname);
route = segmentize(route || '');
let max = Math.max(url.length, route.length);
for (let i=0; i<max; i++) {
if (route[i] && route[i].charAt(0)===':') {
let param = route[i].replace(/(^\:|[+*?]+$)/g, ''),
flags = (route[i].match(/[+*?]+$/) || EMPTY)[0] || '',
let [, param, flags] = /^:(.*?)([+*?]*)$/.exec(route[i]),
plus = ~flags.indexOf('+'),
star = ~flags.indexOf('*'),
val = url[i] || '';
if (!val && !star && (flags.indexOf('?')<0 || plus)) {
ret = false;
break;
}
matches[param] = decodeURIComponent(val);
if (plus || star) {
matches[param] = url.slice(i).map(decodeURIComponent).join('/');
else if (plus || star) {
matches[param] = decodeURIComponent(url.slice(i).join('/'));
break;
}
matches[param] = decodeURIComponent(val);
}
else if (route[i]!==url[i]) {
ret = false;
break;
}
}
if (opts.default!==true && ret===false) return false;

if (search) {
const queryParams = {};
search.split('&').forEach(parameter => {
let [name, ...value] = parameter.split('=');
queryParams[decodeURIComponent(name)] = decodeURIComponent(value.join('='));
});
matches = {
...queryParams,
...matches
};
}
return matches;
}

Expand All @@ -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) {
Expand Down
26 changes: 16 additions & 10 deletions test/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand All @@ -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*'])
);
});

Expand All @@ -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)
);
});
});
Expand Down Expand Up @@ -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' });
});
});
});