router-tree

1.4.3 • Public • Published

router-tree.js

Create routes from directory structure

Current status

NPM version Build Status Dependency Status Dev dependency Status Greenkeeper badge Coverage 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:

  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, 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 paths 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 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
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.

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, 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:

{
  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:

  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.:

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

Package Sidebar

Install

npm i router-tree

Weekly Downloads

0

Version

1.4.3

License

MIT

Unpacked Size

55.3 kB

Total Files

16

Last publish

Collaborators

  • overlookmotel