Version 2 with new API, docs and tests

- API receives array instead of object for match order
- no longer supports unmatched function (just put a catchall route at the end)
This commit is contained in:
Timothy Farrell 2017-01-24 10:02:29 -06:00
parent bb594e1dbf
commit 51926d1c02
4 changed files with 291 additions and 114 deletions

View File

@ -1,49 +1,121 @@
Usage: # Router
Router(rootURL, routeSpec, onUnmatched): The core functions of router that is designed to be slim and have an easy-to-read, yet powerful API.
rootURL (string) the url prefix of all paths. **NOTE** This router is geared toward offline-first apps and thus does not support pushState.
routeSpec (object) # Usage
onUnmatched (optional function(api, badPath, lastGoodRoute)) Called whenever an unmatched route is ## Instantiation
navigated. Parameters are: api (Router instance) badpath (string) lastGoodRoute (object) a route
descriptor object for the last good route.
returns an api object: goto(path) - match the path to a route and navigate there href(pathName, ```js
vars) - build a path based on the name and supplied vars start(initialRoute) - listen to const router = Router(routeSpecArray, rootURL='#'):
window.onhashchange for route changes current() - get the current route descriptor object ```
### routeSpecArray
An array of objects with the following properties:
* `name` _string_ - an optional string that can be referred to in the `href` and `goto` instance
methods. Duplicate names are not allowed.
* `path` _string_ - the path template for this route. Path templates are matched in the order of the
array. Example:
`"/"` - for the root path `"/articles"` - another static path `"/article/:id"` - a path with a
variable `"/:unknownRoute"` - the last route could catch erroneous routes. Unmatched urls will
automatically route here.
* `vars` _object_ - an optional object mapping variable names in the path template to a regular
expression for validation
* `enter` _function_ - a function for when this route is entered. The `enter` function receives two
parameters:
* _route instance object_ - this is a object that contains properties:
* `name` _string_ - the route name
* `vars` _object_ - an object holding any variables parsed from the path
* `path` _string_ - the path as received
* _router instance object_ - (see below)
The `enter` function may return a callback that will be called instead of the `enter` function for
further navigate events that will be handled by this route (with different variables). This allows
`enter` to establish a context for the route it handles.
* `exit` _function_ - an optional function that will be called before calling `enter` of the next
path. `exit` has the option to delay the call to `enter` by returning a promise. This is intended
for handling transition animations. If the route's `enter` function returns a callback, `exit`
will not be called if the same route receives navigation but with different variables. `exit`
receives the parameters similarly to `enter`:
* _route instance object_ - for the route being exited
* _route instance object_ - for the route yet-to-be entered
* _router instance object_ - (see below)
### rootURL (optional string)
The url prefix of all paths. This should always be `#` unless you're nesting routers.
### router (returned object)
The returned instance provides these methods:
* `goto(url: string)` or `goto(pathName: string, vars: object)`
Match to a route by relative url or pathName and a vars object. Navigate there.
* `href(pathName: string, vars: object)`
Build a relative url based on the name and supplied vars.
* `start(initialRoute: string)`
Listen to `window.onhashchange` for route changes. The `initialRoute` will be passed to `goto()`
if the is no current route in `window.location`.
* `stop()`
Cancel subscription to `window.onhashchange`
* `current()`
Get the current _route instance object_ as was provided to the current routes `enter` function.
Example: Example:
```js ```js
const router = Router( const router = Router([
'#',
{ {
home: { name: 'home',
path: '/', path: '/',
onenter: (r, route) => { enter: (r, route) => {
console.log('At application home'); console.log('At application home');
}, },
onexit: (r, route, newRoute) => { exit: (r, route, newRoute) => {
console.log(`Exiting from ${route.path} to ${newRoute.path}`); console.log(`Exiting from ${route.path} to ${newRoute.path}`);
} }
}, },
article: { {
name: 'article',
path: '/article/:id', path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ }, vars: { id: /[a-f0-9]{6,40}/ },
onenter: (r, route) => { enter: (route, router) => {
console.log('Opening Article', route.vars.id); console.log('Opening Article', route.vars.id);
return (route, router) => {
console.log('Opening Article', route.vars.id);
};
}, },
onexit: (r, route, newRoute) => { exit: (route, newRoute, router) => {
console.log('Closing Article', route.vars.id); console.log('Closing Article', route.vars.id);
} }
}
}, },
(r, path, lastGoodRoute) => { {
alert(`Unmatched route "${path}"`); id: '404',
r.goto(lastGoodRoute.path || 'home'); path: ':vars',
enter: r => r.goto('home')
} }
); ]);
router.start('/'); router.start('/');
``` ```

View File

@ -1,6 +1,6 @@
{ {
"name": "router", "name": "router",
"version": "1.1.0", "version": "2.0.0",
"description": "A slim and unopinionated router", "description": "A slim and unopinionated router",
"main": "lib/index.js", "main": "lib/index.js",
"jsnext:main": "src/index.js", "jsnext:main": "src/index.js",

View File

@ -1,24 +1,21 @@
const { Router } = require('../lib/index.js'); const { Router } = require('../lib/index.js');
describe('router builds urls', () => { describe('router.href builds urls', () => {
describe('for hashed routes', () => { const router = Router([
const router = Router(
'#',
{ {
home: { name: 'home',
path: '/', path: '/',
onenter: (r, route) => {}, enter: (route, router) => {},
onexit: (r, route, newRoute) => {} exit: (route, newRoute, router) => {}
}, },
article: { {
name: 'article',
path: '/article/:id', path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ }, vars: { id: /[a-f0-9]{6,40}/ },
onenter: (r, route) => {}, enter: (route, router) => {},
onexit: (r, route, newRoute) => {} exit: (route, newRoute, router) => {}
} }
}, ]);
(r, path, lastGoodRoute) => {}
);
it('at the root', () => { it('at the root', () => {
expect(router.href('home')).toEqual('#/'); expect(router.href('home')).toEqual('#/');
@ -42,45 +39,59 @@ describe('router builds urls', () => {
}); });
}); });
}); });
});
describe('router goes to routes', () => { describe('router.goto()', () => {
describe('for hashed routes', () => { let reEnterHooks, routeSpecArray, router;
const router = Router(
'#', describe('goes to routes', () => {
beforeEach(() => {
reEnterHooks = {
home: () => {},
article: () => {}
};
routeSpecArray = [
{ {
home: { name: 'home',
path: '/', path: '/',
onenter: (r, route) => {}, enter: (route, router) => reEnterHooks.home,
onexit: (r, route, newRoute) => {} exit: (route, newRoute, router) => {}
}, },
article: { {
name: 'article',
path: '/article/:id', path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ }, vars: { id: /[a-f0-9]{6,40}/ },
onenter: (r, route) => {}, enter: (route, router) => reEnterHooks.article,
onexit: (r, route, newRoute) => {} exit: (route, newRoute, router) => {}
} }
}, ];
(r, path, lastGoodRoute) => {}
); spyOn(routeSpecArray[0], 'enter').and.callThrough();
it('at the root', done => { spyOn(routeSpecArray[1], 'enter').and.callThrough();
router.goto('home').then(() => {
const current = router.current(); router = Router(routeSpecArray);
expect(current.name).toEqual('home');
expect(current.path).toEqual('#/');
done();
});
}); });
it('with vars', done => { it('with vars', done => {
router.goto('article', { id: 156234 }).then(() => { router.goto('article', { id: 156234 }).then(() => {
expect(routeSpecArray[1].enter).toHaveBeenCalled();
const current = router.current(); const current = router.current();
expect(current.name).toEqual('article'); expect(current.name).toEqual('article');
expect(current.path).toEqual('#/article/156234'); expect(current.path).toEqual('#/article/156234');
done(); done();
}); });
}); });
it('at the root', done => {
router.goto('home').then(() => {
expect(routeSpecArray[0].enter).toHaveBeenCalled();
const current = router.current();
expect(current.name).toEqual('home');
expect(current.path).toEqual('#/');
done();
});
});
it('with a url', done => { it('with a url', done => {
router.goto('#/article/156233').then(() => { router.goto('#/article/156233').then(() => {
expect(routeSpecArray[1].enter).toHaveBeenCalled();
const current = router.current(); const current = router.current();
expect(current.name).toEqual('article'); expect(current.name).toEqual('article');
expect(current.path).toEqual('#/article/156233'); expect(current.path).toEqual('#/article/156233');
@ -88,4 +99,95 @@ describe('router goes to routes', () => {
}); });
}); });
}); });
describe('reEnters routes', () => {
it('when a route is navigated with merely different vars with a reenter hook', done => {
reEnterHooks = {
home: () => {},
article: () => {}
};
routeSpecArray = [
{
name: 'home',
path: '/',
enter: (route, router) => reEnterHooks.home,
exit: (route, newRoute, router) => {}
},
{
name: 'article',
path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ },
enter: (route, router) => reEnterHooks.article,
exit: (route, newRoute, router) => {}
}
];
spyOn(routeSpecArray[0], 'enter').and.callThrough();
spyOn(routeSpecArray[1], 'enter').and.callThrough();
spyOn(routeSpecArray[1], 'exit').and.callThrough();
spyOn(reEnterHooks, 'article').and.callThrough();
router = Router(routeSpecArray);
router
.goto('article', { id: 156234 })
.then(() => {
expect(routeSpecArray[1].enter).toHaveBeenCalled();
expect(routeSpecArray[1].exit).toHaveBeenCalledTimes(0);
return router.goto('article', { id: 151234 });
})
.then(() => {
expect(reEnterHooks.article).toHaveBeenCalled();
expect(routeSpecArray[1].exit).toHaveBeenCalledTimes(0);
return router.goto('home');
})
.then(() => {
expect(routeSpecArray[0].enter).toHaveBeenCalled();
expect(routeSpecArray[1].exit).toHaveBeenCalled();
})
.then(done);
});
it('when a route is navigated with merely different vars without a reenter hook', done => {
routeSpecArray = [
{
name: 'home',
path: '/',
enter: (route, router) => {},
exit: (route, newRoute, router) => {}
},
{
name: 'article',
path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ },
enter: (route, router) => {},
exit: (route, newRoute, router) => {}
}
];
spyOn(routeSpecArray[0], 'enter').and.callThrough();
spyOn(routeSpecArray[1], 'enter').and.callThrough();
spyOn(routeSpecArray[1], 'exit').and.callThrough();
router = Router(routeSpecArray);
router
.goto('article', { id: 156234 })
.then(() => {
expect(routeSpecArray[1].enter).toHaveBeenCalled();
expect(routeSpecArray[1].exit).toHaveBeenCalledTimes(0);
return router.goto('article', { id: 151234 });
})
.then(() => {
expect(routeSpecArray[1].enter).toHaveBeenCalledTimes(2);
expect(routeSpecArray[1].exit).toHaveBeenCalledTimes(1);
return router.goto('home');
})
.then(() => {
expect(routeSpecArray[0].enter).toHaveBeenCalled();
expect(routeSpecArray[1].exit).toHaveBeenCalled();
})
.then(done);
});
});
}); });

View File

@ -4,8 +4,7 @@ const VARMATCH_RE = /:([^\/]+)/g;
const ROUTEELEMENT_RE = /^[^\/]+$/; const ROUTEELEMENT_RE = /^[^\/]+$/;
const nop = () => 1; const nop = () => 1;
const digestRoutes = (routes, baseUrl) => const digestRoutes = (routes, baseUrl) =>
ObjectKeys(routes).map(name => { routes.map((route, i) => {
const route = routes[name];
const reg = route.path.replace(VARMATCH_RE, (m, varName) => { const reg = route.path.replace(VARMATCH_RE, (m, varName) => {
const varDef = route.vars || {}; const varDef = route.vars || {};
const regExStr = '' + (varDef[varName] || /[^\/]+/); const regExStr = '' + (varDef[varName] || /[^\/]+/);
@ -14,19 +13,23 @@ const digestRoutes = (routes, baseUrl) =>
return { return {
matcher: new RegExp(`^${baseUrl}${reg}$`), matcher: new RegExp(`^${baseUrl}${reg}$`),
name, _i: i,
...route ...route
}; };
}); });
export function Router(baseUrl, routes, unmatched) { export function Router(routes, baseUrl = '#') {
let listening = false; let listening = false;
let currentRoute = Null; let currentRoute = Null;
let reEnterHook = Null; let reEnterHook = Null;
let routeMatcher = digestRoutes(routes, baseUrl); let routeMatcher = digestRoutes(routes, baseUrl);
let routeByName = routeMatcher.reduce(
(obj, route) => (route.name ? Object.assign(obj, { [route.name]: route }) : obj),
{}
);
function goto(urlOrName, vars) { function goto(urlOrName, vars, fromLocation) {
const url = urlOrName.startsWith(baseUrl) ? urlOrName : href(urlOrName, vars); const url = urlOrName.startsWith(baseUrl) ? urlOrName : href(urlOrName, vars);
if (listening && _location() !== url) { if (listening && _location() !== url) {
@ -51,7 +54,7 @@ export function Router(baseUrl, routes, unmatched) {
if (path.indexOf(':') !== -1) { if (path.indexOf(':') !== -1) {
match.shift(); match.shift();
path.replace(VARMATCH_RE, (_, varName) => { path.replace(VARMATCH_RE, (_, varName) => {
// We're abusing RegExp.replace here. // We're abusing RegExp.replace here and it's awesome!
routeVars[varName] = match.shift(); routeVars[varName] = match.shift();
}); });
} }
@ -63,33 +66,33 @@ export function Router(baseUrl, routes, unmatched) {
let newRoute = { let newRoute = {
name: routeMatch.name, name: routeMatch.name,
vars: routeVars, vars: routeVars,
path: url path: url,
_i: routeMatch._i
}; };
if (currentRoute && currentRoute.name === newRoute.name && isFunction(reEnterHook)) { if (currentRoute && currentRoute._i === newRoute._i && isFunction(reEnterHook)) {
reEnterHook(newRoute); const result = reEnterHook(newRoute);
currentRoute = newRoute; currentRoute = newRoute;
return Promise.resolve(result);
} else { } else {
let onexit = currentRoute && currentRoute.name ? routes[currentRoute.name].onexit || nop : nop; let exit = currentRoute && currentRoute._i ? routes[currentRoute._i].exit || nop : nop;
return Promise.resolve(onexit(api, currentRoute, newRoute)) return Promise.resolve(exit(api, currentRoute, newRoute))
.catch(() => {}) .catch(() => {})
.then(() => { .then(() => {
reEnterHook = routes[routeMatch.name].onenter(api, newRoute); reEnterHook = routes[routeMatch._i].enter(api, newRoute);
currentRoute = newRoute; currentRoute = newRoute;
}); });
} }
} else if (unmatched) { } else if (currentRoute && fromLocation) {
unmatched(api, url, currentRoute); // If we are listening and we receive an unmatched path, go back.
} else {
if (currentRoute && listening) {
location.hash = currentRoute.path; location.hash = currentRoute.path;
return; return;
} }
// Either we received a goto call or a start call to in invalid path.
throw new Error(`No route for "${url}"`); throw new Error(`No route for "${url}"`);
} }
}
function href(routeName, vars) { function href(routeName, vars) {
const route = routes[routeName]; const route = routeByName[routeName];
if (!route) { if (!route) {
throw new Error(`Invalid route ${routeName}.`); throw new Error(`Invalid route ${routeName}.`);
} }
@ -115,7 +118,7 @@ export function Router(baseUrl, routes, unmatched) {
} }
function _handler() { function _handler() {
goto(_location()); goto(_location(), Null, true);
} }
function start(initialRoute) { function start(initialRoute) {