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:
parent
bb594e1dbf
commit
51926d1c02
@ -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: {
|
||||
name: 'home',
|
||||
path: '/',
|
||||
onenter: (r, route) => {
|
||||
enter: (r, route) => {
|
||||
console.log('At application home');
|
||||
},
|
||||
onexit: (r, route, newRoute) => {
|
||||
exit: (r, route, newRoute) => {
|
||||
console.log(`Exiting from ${route.path} to ${newRoute.path}`);
|
||||
}
|
||||
},
|
||||
article: {
|
||||
{
|
||||
name: 'article',
|
||||
path: '/article/:id',
|
||||
vars: { id: /[a-f0-9]{6,40}/ },
|
||||
onenter: (r, route) => {
|
||||
enter: (route, router) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
(r, path, lastGoodRoute) => {
|
||||
alert(`Unmatched route "${path}"`);
|
||||
r.goto(lastGoodRoute.path || 'home');
|
||||
{
|
||||
id: '404',
|
||||
path: ':vars',
|
||||
enter: r => r.goto('home')
|
||||
}
|
||||
);
|
||||
]);
|
||||
router.start('/');
|
||||
```
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,24 +1,21 @@
|
||||
const { Router } = require('../lib/index.js');
|
||||
|
||||
describe('router builds urls', () => {
|
||||
describe('for hashed routes', () => {
|
||||
const router = Router(
|
||||
'#',
|
||||
describe('router.href builds urls', () => {
|
||||
const router = Router([
|
||||
{
|
||||
home: {
|
||||
name: 'home',
|
||||
path: '/',
|
||||
onenter: (r, route) => {},
|
||||
onexit: (r, route, newRoute) => {}
|
||||
enter: (route, router) => {},
|
||||
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) => {},
|
||||
exit: (route, newRoute, router) => {}
|
||||
}
|
||||
},
|
||||
(r, path, lastGoodRoute) => {}
|
||||
);
|
||||
]);
|
||||
|
||||
it('at the root', () => {
|
||||
expect(router.href('home')).toEqual('#/');
|
||||
@ -41,46 +38,60 @@ describe('router builds urls', () => {
|
||||
}).toThrowError(Error, 'Invalid value for route /article/:id var id: 156.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('router goes to routes', () => {
|
||||
describe('for hashed routes', () => {
|
||||
const router = Router(
|
||||
'#',
|
||||
describe('router.goto()', () => {
|
||||
let reEnterHooks, routeSpecArray, router;
|
||||
|
||||
describe('goes to routes', () => {
|
||||
beforeEach(() => {
|
||||
reEnterHooks = {
|
||||
home: () => {},
|
||||
article: () => {}
|
||||
};
|
||||
routeSpecArray = [
|
||||
{
|
||||
home: {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
} 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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user