router-tree
v1.4.3
Published
Create routes from directory structure
Downloads
6
Maintainers
Readme
router-tree.js
Create routes from directory structure
Current status
What's it for?
Often defining routes involves a lot of boilerplate code. This modules aims to reduce that, based on 3 simple principles:
- Define routes structure as a tree of files and folders
- Group together server-side controllers, views, and client-side components
- 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, or React Router (or both!)
Usage
Loading files
Async (Promises)
const routerTree = require('router-tree');
const tree = await routerTree('/path/to/routes', {/* options */});
Sync
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:
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:
{
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
:
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
:
module.exports = { pathPart: null, param: 'artistId' };
(pathPart: null
removes the "view" part from the path).
Now, the route tree is as follows:
{
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 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:
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 component for each (or some of) the routes, use the types
option:
const tree = await routerTree('/path/to/routes', {
types: { react: 'jsx' }
} );
Now if you add a file /index.jsx
, the resulting route tree looks like:
{
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.
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:
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:
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:
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 routechildren
- Object containing references to all child routes, keyed by each child'sname
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 thepath
for this route e.g.'display'
ornull
for nothing (defaults toroute.name
)param
- Name of param to add to thepath
e.g.'artistId'
(defaultnull
)endSlash
- Iftrue
, adds a final/
to end of the path (defaultfalse
)companions
- (see below)
Methods:
initProps()
- Called within class constructor, before properties supplied to constructor are applied to Route instanceinit()
- Called after parentage is deduced, but beforepath
is built (default is no-op)initPath()
- Builds routepath
. By default, usespathPart
,param
andendSlash
(as shown above), but can be overriden
Lifecycle
Loading occurs in the following order:
- Directory scanned for files
- Route files loaded using Node's
require()
- Internal paths calculated from file paths
- Route files exporting plain objects (or
null
) converted to instances ofRoute
.initProps()
method called on each node- Companions (see below) added to routes
- Associated files added to
files
object on routes - Parentage of all nodes determined by reference to
parentPath
property - Route tree built - all properties noted above are set
.init()
method called on each node, starting at root and working up the tree.initPath()
method called on each node- Children sorted by path (static paths before dynamic paths)
- 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 filesoptions.filterFolders
filters out folders and all the files they contain
Each option can be either:
RegExp
- which matches filenames to includeFunction
- which receives filename and returnstrue
to include them
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'sparentPath
relative to its owninternalPath
.
| 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.
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:
{
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
, usetypes: { 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:
{
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.
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.
// Route loader
const tree = await routerTree('/path/to/routes', {
context: {
msg: 'Hello!',
models: databaseModels
}
} );
// '/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.
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:
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:
- attribute name they are defined with (relative path)
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.:
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:
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.:
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.
const routes = routerTree.flatten( tree );
Tests
Use npm test
to run the tests. Use npm run cover
to check coverage.
Changelog
See 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