router-tree
Version:
Create routes from directory structure
663 lines (461 loc) • 20.7 kB
Markdown
# router-tree.js
# Create routes from directory structure
## Current status
[](https://www.npmjs.com/package/router-tree)
[](http://travis-ci.org/overlookmotel/router-tree)
[](https://david-dm.org/overlookmotel/router-tree)
[](https://david-dm.org/overlookmotel/router-tree)
[](https://greenkeeper.io/)
[](https://coveralls.io/r/overlookmotel/router-tree)
## What's it for?
Often defining routes involves a lot of boilerplate code. This modules aims to reduce that, based on 3 simple principles:
1. Define routes structure as a tree of files and folders
2. Group together server-side controllers, views, and client-side components
3. Use classes to keep route definitions DRY
This module will load route files from a directory structure according to config you provide, and turn it into a tree of routes. You can do what you want with the tree from there e.g. feed into [Express](https://www.npmjs.com/package/express), or [React Router](https://reacttraining.com/react-router/) (or both!)
## Usage
### Loading files
#### Async (Promises)
```js
const routerTree = require('router-tree');
const tree = await routerTree('/path/to/routes', {/* options */});
```
#### Sync
```js
const tree = routerTree.sync('/path/to/routes', {/* options */});
```
### Route tree structure
#### Principles
Each route "sits on top" of another route. Each route has 1 parent and may have many children. e.g.:
```
/
/artists
/artists/:artistId
/artists/:artistId/albums
/login
```
* `/` is the root node. It has no parent.
* `/artists` sits on top of `/`
* `/artists/:artistId` sits on top of `/artists`
* `/artists/:artistId/albums` sits on top of `/artists/:artistId`
* `/login` sits on top of `/`
#### How to create this structure
With router-tree, you would create the following files:
(there are other ways too, it's very configurable - keep reading!)
```
/ -> /index.js
/artists -> /artists/index.js
/artists/:artistId -> /artists/view.js
/artists/:artistId/albums -> /artists/albums/index.js
/login -> /login.js
```
Each file can just contain this for now:
```js
module.exports = {};
```
#### Loading
When router-tree loads the routes, it converts each route file into an instance of the `routerTree.Route` class.
Each route is given an "internal path". By default, a file path ending `/index.js` creates an internal path with the `/index.js` part taken off. A file path with any other ending just chops off the `.js` file extension.
So now we have the following internal paths:
```
/
/artists
/artists/view
/artists/albums
/login
```
To turn this into a tree, each route's parent is identified, and router-tree returns an object like this:
```js
{
internalPath: '/',
children: {
artists: {
internalPath: '/artists',
children: {
view: { internalPath: '/artists/view' },
albums: { internalPath: '/artists/albums' }
}
},
login: { internalPath: '/login' }
}
}
```
Each node also has a `parent` property, pointing back to that node's parent. For the root node `/`, it is `null`.
Children are ordered with static paths first (i.e. `/artists/albums` before `/artists/:artistId`) - the order you would match the routes in.
#### Setting parentage
In the example above, `/artists/albums` is in the wrong place. It should be a child of `/artists/view` not `/artists`.
We can rectify this by adding a `parentPath` property to `/artists/albums/index.js`:
```js
module.exports = { parentPath: './view' };
```
NB `parentPath` can be absolute, but here we are using a relative path.
#### Routing paths
The "internal path" is based on the file/folder structure of the files we loaded in. But we may want the actual routing paths to be different.
e.g. The routing path for `/artists/view.js` is meant to be `/artists/:artistId` not `/artists/view`.
We can achieve this by setting `pathPart` and `param` properties in `/artists/view.js`:
```js
module.exports = { pathPart: null, param: 'artistId' };
```
(`pathPart: null` removes the "view" part from the path).
Now, the route tree is as follows:
```js
{
internalPath: '/',
path: '/',
children: {
artists: {
internalPath: '/artists',
path: '/artists',
children: {
view: {
internalPath: '/artists/view',
path: '/artists/:artistId',
children: {
albums: {
internalPath: '/artists/albums',
path: '/artists/:artistId/albums'
}
}
}
}
},
login: {
internalPath: '/login',
path: '/login'
}
}
}
```
Note that the path for `/artists/albums` also includes `:artistId`. This happens automatically as each route's path builds upon its parent's.
We now have routes with the following paths:
```
/
/artists
/artists/:artistId
/artists/:artistId/albums
/login
```
#### So what do we do with the tree?
That's where router-tree hands over to you.
It would be easy, for example, to traverse the tree and register a route with [Express](https://www.npmjs.com/package/express) for each node, using a property of the route file as the handler.
In each route file create a method `getHandler()` on the exported object. And:
```js
const app = express();
const tree = await routerTree('/path/to/routes');
routerTree.traverse(tree, route => {
if (route.getHandler) app.get(route.path, route.getHandler);
} );
```
(`routerTree.traverse()` is a helper method that comes with the library - see below)
But there's a lot more...
### Associated resources
The route files that we've seen so far are purely to map the routing structure. What about client-side components?
You can associate any other files you like with each route.
If you want to provide a [React](https://reactjs.org/) component for each (or some of) the routes, use the `types` option:
```js
const tree = await routerTree('/path/to/routes', {
types: { react: 'jsx' }
} );
```
Now if you add a file `/index.jsx`, the resulting route tree looks like:
```js
{
internalPath: '/',
path: '/',
files: { react: '/index.jsx' },
children: {/* ... */}
}
```
The `.jsx` file has not been loaded, but it's been associated with the route. You could now traverse the route tree, in the same way as the Express example above, to build a [React Router](https://reacttraining.com/react-router/).
### Route classes
Every route file loaded is converted to an instance of `routerTree.Route` class.
#### Using the `Route` class directly
You can define routes using this class directly:
```js
new Route( {/* props */} )
new Route( { parentPath: '../' } )
```
#### Subclassing `Route`
Creating custom subclasses of `Route` can abstract common properties/behaviours shared by multiple routes.
For example, the features of the `/artists/view` route we saw earlier can be abstracted so they can be reused on other similar routes:
```js
const {Route} = require('router-tree');
const {singularize} = require('inflection');
class ViewRoute extends Route {
init() {
super.init();
this.pathPart = null;
this.param = `${singularize(this.name)}Id`;
}
}
```
NB The `init()` method is called on every node before the `path`s are built.
Now `/artists/view.js` can simply contain:
```js
module.exports = new ViewRoute();
```
If you want to add another route `/artists/:artistId/albums/:albumId`, just use the `ViewRoute` class again. See, no boilerplate!
#### More
You can also use Route classes to achieve much more powerful effects if a lot of your routes are similar e.g. CRUD (see section on "Companions" below).
### Anatomy of a Route
Each route object has the following properties:
Defined by router-tree:
* `name` - Name of the route (from the filename) e.g. `'view'`
* `internalPath` - Internal path e.g. `'/artists/view'`
* `sourcePath` - Path to the source file e.g. `'/artists/view.js'`
* `parent` - Reference to the parent route
* `children` - Object containing references to all child routes, keyed by each child's `name`
* `files` - Object containing paths to any files attached to this route e.g. `{ react: '/artists/view.jsx' }`
User-definable:
* `path` - External path for the route e.g. `'/artists/:artistId'` (if not defined, router-tree will build)
* `parentPath` - Relative or absolute path to parent route e.g. `'/artists'`, `'./view'`, `'../'` (default `'./'`)
* `pathPart` - Text to add to the `path` for this route e.g. `'display'` or `null` for nothing (defaults to `route.name`)
* `param` - Name of param to add to the `path` e.g. `'artistId'` (default `null`)
* `endSlash` - If `true`, adds a final `/` to end of the path (default `false`)
* `companions` - (see below)
Methods:
* `initProps()` - Called within class constructor, before properties supplied to constructor are applied to Route instance
* `init()` - Called after parentage is deduced, but before `path` is built (default is no-op)
* `initPath()` - Builds route `path`. By default, uses `pathPart`, `param` and `endSlash` (as shown above), but can be overriden
### Lifecycle
Loading occurs in the following order:
1. Directory scanned for files
2. Route files loaded using Node's `require()`
3. Internal paths calculated from file paths
4. Route files exporting plain objects (or `null`) converted to instances of `Route`
5. `.initProps()` method called on each node
6. Companions (see below) added to routes
7. Associated files added to `files` object on routes
8. Parentage of all nodes determined by reference to `parentPath` property
9. Route tree built - all properties noted above are set
10. `.init()` method called on each node, starting at root and working up the tree
11. `.initPath()` method called on each node
12. Children sorted by path (static paths before dynamic paths)
13. Tree returned
Therefore:
* Properties which affect parentage must be set as initial properties or in a `Route` subclass constructor or `.initProps()` method.
* Properties which affect the `path` must be set in `.init()` method at latest.
### Loading options
#### Filters
Files/folders can be skipped by using filter options.
* `options.filterFiles` filters out files
* `options.filterFolders` filters out folders and all the files they contain
Each option can be either:
1. `RegExp` - which matches filenames to include
2. `Function` - which receives filename and returns `true` to include them
```js
const tree = await routerTree('/path/to/routes', {
// Skip test files
filterFiles: filename => filename.slice(-8) == '.test.js',
// Skip folders starting with '_'
filterFolders: /^[^_]/
} );
```
NB Files are also filtered by file extension according to the `types` option (see below), in addition to filtering by `options.filterFiles`.
#### Filesystem concurrency
Maximum number of concurrent filesystem operations can be set with `maxConcurrent` option. Default is `5`.
Does not apply to `routerTree.sync()`.
### Defining parentage
Parentage (i.e. which route is a child of which) is resolved according to the `parentPath` attribute of each route. You can create the route tree in any shape you want by setting `parentPath` accordingly.
Resolution of relative paths is similar to Node's `require()`. i.e. relative to the *folder* that the file is in.
Absolute paths start with `/`. They are absolute relative to the root *of the directory routes are loaded from*, not filesystem root.
Each route's `internalPath` is the file path minus the extension. Files named `index` are referenced by the path of the folder they are in.
A route's parent is:
> the route with an `internalPath` which equals the path you get by resolving the child's `parentPath` relative to its own `internalPath`.
| Source path | `internalPath` | `parentPath` | Parent resolves to |
|--------------------------|-----------------|--------------|--------------------|
| /index.js | / | null | null |
| /artists/index.js | /artists | ./ | / |
| /artists/view.js | /artists/view | ./ | /artists |
| /artists/edit.js | /artists/edit | ./view | /artists/view |
| /artists/albums/index.js | /artists/albums | ./view | /artists/view |
| /artists/new.js | /artists/new | /artists | /artists |
Default for `parentPath` if not defined is `'./'`, except for the root node which is `null`.
As a shortcut, relative paths can be defined without a prepended `./` i.e. `'view'` is the same as `'./view'`. router-tree will add the `./` automatically.
### Associated files
You can associate additional files with routes by using the `types` option.
Files are identified by file extension.
```js
const tree = await routerTree('/path/to/routes', {
types: {
route: 'js',
react: 'jsx',
controller: 'cont.js',
ignore: 'test.js'
}
} );
```
If you have the following files:
```
/index.js
/index.jsx
/index.cont.js
/index.test.js
```
the result returned is:
```js
{
path: '/',
...
files: {
route: '/index.js',
react: '/index.jsx',
controller: '/index.cont.js'
}
}
```
#### `route` type
The `route` type is the files which are actually loaded as route nodes. This defaults to `'js'`.
* To define your routes as JSON files, use `types: { route: 'json' }`
* To only load route files with extension `.route.js`, use `types: { route: 'route.js' }`
#### `ignore` type
Defining an `ignore` type tells router-tree to ignore files with this extension.
#### Implicit routes
You don't need to provide a route file to create a route. Just the presence of an associated file defined in `types` will implicitly create a route with default options.
e.g. Adding a file `/view.jsx` creates a route `/view` with the following properties:
```js
{
path: '/view',
name: 'view',
internalPath: '/view',
sourcePath: null, // Because no route file
parentPath: './', // The default
files: { react: '/view.jsx' },
parent: ..., // Reference to '/' route
children: { ... }
}
```
#### Notes
router-tree attempts to match with the longest extension first. Hence why `/index.cont.js` gets identified as a controller (`.cont.js`), not a route (`.js`).
Types can also be defined as an array of extensions e.g. `types: { view: [ 'html', 'ejs' ] }`.
### Class options
Any route files that export a plain object (or `null`, or indeed anything else which isn't an instance of `routerTree.Route` class) is converted to an instance of `Route`.
If a route is created implicitly by the presence of an associated file (due to `types` option), that route is also a new instance of `Route` class.
`defaultRouteClass` option sets the default class to create routes from. It **must** be a subclass of `Route` itself.
```js
const routerTree = require('routerTree');
class MyRouteClass extends routerTree.Route { ... }
const tree = await routerTree('/path/to/routes', {
defaultRouteClass: MyRouteClass
} );
assert( tree instanceof MyRouteClass );
```
### Context injection
You can inject arbitrary external data into the route bootstrapping process with the `context` option.
This can be useful for e.g. passing in models which routes can bind to them.
The `context` object provided is passed to the `.init()` method of each route.
```js
// Route loader
const tree = await routerTree('/path/to/routes', {
context: {
msg: 'Hello!',
models: databaseModels
}
} );
```
```js
// '/artists' route definition
const {Route} = require('routerTree');
class MyRoute extends Route {
init(context) {
super.init(context);
console.log(context.msg); // Logs 'Hello!'
this.model = context.models.Artist;
}
}
module.exports = new MyRoute();
```
### Overriding path construction
The `path` for each route is constructed by the `.initPath()` method on each route object.
It can be overriden in a `Route` subclass.
```js
const {Route} = require('routerTree');
class MyRoute extends Route {
initPath() {
const path = super.initPath();
// Modify path in some way
return path;
}
}
```
### Companions
To reduce boilerplate, you can define a set of several routes in one file. The additional routes are "companions" of the route they are defined in.
For example, to create a `Route` subclass that provides routes for all the classic CRUD actions:
```js
const {Route} = require('routerTree');
class CrudRoute extends Route {
constructor(props) {
super(props);
Object.assign(this.companions, {
view: { pathPart: null, param: 'id' },
edit: { parentPath: './view' },
delete: { parentPath: './view' },
new: {}
} );
}
}
```
Creating a route file in `/artists/index.js` with `module.exports = new CrudRoute()` will create routes with the following paths:
```
/artists
/artists/:id
/artists/:id/edit
/artists/:id/delete
/artists/new
```
Companion routes are added before `.init()` is called, so must be added in the class constructor or in `.initProps()`.
#### Paths
Why call them "companions" rather than just "children"? Well, they may *not be* children. In the example above `/artists/view` is a child of `/artists` but `/artists/edit` and `/artists/delete` are not - their parent is `/artists/view`.
Adding companions is like adding a folder of route files next to the route file which defines them (or files in the *same* folder if the main route file is `index.js`). The companion routes end up in the route tree the same as routes defined in their own files would.
Where they end up in the route tree depends on:
1. attribute name they are defined with (relative path)
2. `parentPath` defined in each
i.e. `this.companions.view = ...` creates a route with relative path of `'./view'`. The `internalPath` of the companion is the `internalPath` of the main route + the relative path.
Same as with `parentPath`, the prepended `./` in relative paths can be left off - `'view'` is the same as `'./view'`.
Unlike `parentPath`, the relative path is relative to the route *file*, not its containing folder.
You can define companions with any relative or absolute path. e.g.:
```js
this.companions['../view'] = ...
this.companions['./folder/subfolder'] = ...
this.companions['/absolute'] = ...
```
#### Real files take precedence
If there is a real file in the directory structure `/artists/view.js` this takes precedence over the `view` companion which is competing for the same place.
#### Associated files
Any associated files found according to the `types` option will be attached to the companion route, same as they would to a "real" route.
### Utilities
#### `routerTree.traverse( tree, fn )`
Helper method to traverse every node of `tree`, starting at the root node and working up the tree. `fn()` is called with each node in turn.
e.g. to log all routes' paths:
```js
routerTree.traverse( tree, route => console.log(route.path) );
```
#### `routerTree.traverseAsync( tree, fn [, options] )`
Helper method to traverse every node of `tree` asynchronously, starting at the root node and working up the tree. `fn()` is called with each node in turn.
If `fn` returns a promise, the promise is awaited before calling `fn` on the route's children.
Concurrency (i.e. max number of routes `fn` is being run on simultaneously) can be set with `options.concurrency`. Default is no concurrency limit.
e.g.:
```js
await routerTree.traverseAsync(
tree,
async function(route) {/* do something async */},
{ concurrency: 5 }
);
```
#### `routerTree.flatten( tree )`
Helper method to flatten route tree into an array of routes.
```js
const routes = routerTree.flatten( tree );
```
## Tests
Use `npm test` to run the tests. Use `npm run cover` to check coverage.
## Changelog
See [changelog.md](https://github.com/overlookmotel/router-tree/blob/master/changelog.md)
## Issues
If you discover a bug, please raise an issue on Github. https://github.com/overlookmotel/router-tree/issues
## Contribution
Pull requests are very welcome. Please:
* ensure all tests pass before submitting PR
* add an entry to changelog
* add tests for new features
* document new functionality/API additions in README