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 7013a3c050
commit e9e9da0d32
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
navigated. Parameters are: api (Router instance) badpath (string) lastGoodRoute (object) a route
descriptor object for the last good route.
## Instantiation
returns an api object: goto(path) - match the path to a route and navigate there href(pathName,
vars) - build a path based on the name and supplied vars start(initialRoute) - listen to
window.onhashchange for route changes current() - get the current route descriptor object
```js
const router = Router(routeSpecArray, rootURL='#'):
```
### 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:
```js
const router = Router(
'#',
const router = Router([
{
home: {
path: '/',
onenter: (r, route) => {
console.log('At application home');
},
onexit: (r, route, newRoute) => {
console.log(`Exiting from ${route.path} to ${newRoute.path}`);
}
name: 'home',
path: '/',
enter: (r, route) => {
console.log('At application home');
},
article: {
path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ },
onenter: (r, route) => {
console.log('Opening Article', route.vars.id);
},
onexit: (r, route, newRoute) => {
console.log('Closing Article', route.vars.id);
}
exit: (r, route, newRoute) => {
console.log(`Exiting from ${route.path} to ${newRoute.path}`);
}
},
(r, path, lastGoodRoute) => {
alert(`Unmatched route "${path}"`);
r.goto(lastGoodRoute.path || 'home');
{
name: 'article',
path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ },
enter: (route, router) => {
console.log('Opening Article', route.vars.id);
return (route, router) => {
console.log('Opening Article', route.vars.id);
};
},
exit: (route, newRoute, router) => {
console.log('Closing Article', route.vars.id);
}
},
{
id: '404',
path: ':vars',
enter: r => r.goto('home')
}
);
]);
router.start('/');
```

View File

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

View File

@ -1,86 +1,97 @@
const { Router } = require('../lib/index.js');
describe('router builds urls', () => {
describe('for hashed routes', () => {
const router = Router(
'#',
{
home: {
path: '/',
onenter: (r, route) => {},
onexit: (r, route, newRoute) => {}
},
article: {
path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ },
onenter: (r, route) => {},
onexit: (r, route, newRoute) => {}
}
},
(r, path, lastGoodRoute) => {}
);
describe('router.href builds urls', () => {
const router = Router([
{
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) => {}
}
]);
it('at the root', () => {
expect(router.href('home')).toEqual('#/');
it('at the root', () => {
expect(router.href('home')).toEqual('#/');
});
it('with variables', () => {
expect(router.href('article', { id: 156234 })).toEqual('#/article/156234');
});
describe('but throws an error', () => {
it("if the route doesn't exist", () => {
expect(() => {
router.href('artcle', { id: 156 });
}).toThrowError(Error, 'Invalid route artcle.');
});
it('with variables', () => {
expect(router.href('article', { id: 156234 })).toEqual('#/article/156234');
});
describe('but throws an error', () => {
it("if the route doesn't exist", () => {
expect(() => {
router.href('artcle', { id: 156 });
}).toThrowError(Error, 'Invalid route artcle.');
});
it("if the vars don't match", () => {
expect(() => {
router.href('article', { id: 156 });
}).toThrowError(Error, 'Invalid value for route /article/:id var id: 156.');
});
it("if the vars don't match", () => {
expect(() => {
router.href('article', { id: 156 });
}).toThrowError(Error, 'Invalid value for route /article/:id var id: 156.');
});
});
});
describe('router goes to routes', () => {
describe('for hashed routes', () => {
const router = Router(
'#',
{
home: {
describe('router.goto()', () => {
let reEnterHooks, routeSpecArray, router;
describe('goes to routes', () => {
beforeEach(() => {
reEnterHooks = {
home: () => {},
article: () => {}
};
routeSpecArray = [
{
name: 'home',
path: '/',
onenter: (r, route) => {},
onexit: (r, route, newRoute) => {}
enter: (route, router) => reEnterHooks.home,
exit: (route, newRoute, router) => {}
},
article: {
{
name: 'article',
path: '/article/:id',
vars: { id: /[a-f0-9]{6,40}/ },
onenter: (r, route) => {},
onexit: (r, route, newRoute) => {}
enter: (route, router) => reEnterHooks.article,
exit: (route, newRoute, router) => {}
}
},
(r, path, lastGoodRoute) => {}
);
it('at the root', done => {
router.goto('home').then(() => {
const current = router.current();
expect(current.name).toEqual('home');
expect(current.path).toEqual('#/');
done();
});
];
spyOn(routeSpecArray[0], 'enter').and.callThrough();
spyOn(routeSpecArray[1], 'enter').and.callThrough();
router = Router(routeSpecArray);
});
it('with vars', done => {
router.goto('article', { id: 156234 }).then(() => {
expect(routeSpecArray[1].enter).toHaveBeenCalled();
const current = router.current();
expect(current.name).toEqual('article');
expect(current.path).toEqual('#/article/156234');
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 => {
router.goto('#/article/156233').then(() => {
expect(routeSpecArray[1].enter).toHaveBeenCalled();
const current = router.current();
expect(current.name).toEqual('article');
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 nop = () => 1;
const digestRoutes = (routes, baseUrl) =>
ObjectKeys(routes).map(name => {
const route = routes[name];
routes.map((route, i) => {
const reg = route.path.replace(VARMATCH_RE, (m, varName) => {
const varDef = route.vars || {};
const regExStr = '' + (varDef[varName] || /[^\/]+/);
@ -14,19 +13,23 @@ const digestRoutes = (routes, baseUrl) =>
return {
matcher: new RegExp(`^${baseUrl}${reg}$`),
name,
_i: i,
...route
};
});
export function Router(baseUrl, routes, unmatched) {
export function Router(routes, baseUrl = '#') {
let listening = false;
let currentRoute = Null;
let reEnterHook = Null;
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);
if (listening && _location() !== url) {
@ -51,7 +54,7 @@ export function Router(baseUrl, routes, unmatched) {
if (path.indexOf(':') !== -1) {
match.shift();
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();
});
}
@ -63,33 +66,33 @@ export function Router(baseUrl, routes, unmatched) {
let newRoute = {
name: routeMatch.name,
vars: routeVars,
path: url
path: url,
_i: routeMatch._i
};
if (currentRoute && currentRoute.name === newRoute.name && isFunction(reEnterHook)) {
reEnterHook(newRoute);
if (currentRoute && currentRoute._i === newRoute._i && isFunction(reEnterHook)) {
const result = reEnterHook(newRoute);
currentRoute = newRoute;
return Promise.resolve(result);
} else {
let onexit = currentRoute && currentRoute.name ? routes[currentRoute.name].onexit || nop : nop;
return Promise.resolve(onexit(api, currentRoute, newRoute))
let exit = currentRoute && currentRoute._i ? routes[currentRoute._i].exit || nop : nop;
return Promise.resolve(exit(api, currentRoute, newRoute))
.catch(() => {})
.then(() => {
reEnterHook = routes[routeMatch.name].onenter(api, newRoute);
reEnterHook = routes[routeMatch._i].enter(api, newRoute);
currentRoute = newRoute;
});
}
} else if (unmatched) {
unmatched(api, url, currentRoute);
} else {
if (currentRoute && listening) {
location.hash = currentRoute.path;
return;
}
throw new Error(`No route for "${url}"`);
} else if (currentRoute && fromLocation) {
// If we are listening and we receive an unmatched path, go back.
location.hash = currentRoute.path;
return;
}
// Either we received a goto call or a start call to in invalid path.
throw new Error(`No route for "${url}"`);
}
function href(routeName, vars) {
const route = routes[routeName];
const route = routeByName[routeName];
if (!route) {
throw new Error(`Invalid route ${routeName}.`);
}
@ -115,7 +118,7 @@ export function Router(baseUrl, routes, unmatched) {
}
function _handler() {
goto(_location());
goto(_location(), Null, true);
}
function start(initialRoute) {