As I explained in an earlier post,
I decided to create a command line tool which would be invoked by
an npm run
command.
The source code of electrum-require-components
is
on GitHub.
What does it do?
The tool will be invoked like this:
electrum-require-components --wrap ./src components .component.js auto.js
It starts in ./src
where it walks recursively through the subdirectories,
starting with components
. In every subdirectory, it looks for all files
which end with .comp.js
. Then, it outputs a series of imports and exports
into source file auto.js
:
'use strict';
import {E} from 'electrum';
import Overlay from './components/extras/Overlay.component.js';
import Footer from './components/footers/Footer.component.js';
import Link from './components/links/Link.component.js';
module.exports.Overlay = E.wrapComponent ('Overlay', Overlay);
module.exports.Footer = E.wrapComponent ('Footer', Footer);
module.exports.Link = E.wrapComponent ('Link', Link);
The --wrap
options adds the calls to E.wrapComponent()
.
Walking the directories
It is the first project I wrote for node.js and I wanted to be a good citizen. So I decided to go asynchrounous all the way down.
To explain how this works, I’ll take the example of the traverse()
function which given a root
directory and an array dirs
of
intermediate directories calls the collect
callback on every
file entry, and when it is done, it calls the last provided callback.
let result = [];
function collect (dirs, file) {
const filePath = [...dirs, file].join ('/');
result.push ([name, filePath]);
}
traverse ('./src', ['components'], collect, err => {
if (err) {
next (err);
} else {
next (err, result);
}
});
And here comes the code for the traverse
function:
function traverse (root, dirs, collect, next) {
const rootPath = path.join (root, ...dirs);
fs.lstat (rootPath, (err, stats) => {
if (err) {
next (err);
} else {
// ...
}
});
}
The first asynchronous operation is the call to fs.lstat()
which is used to find out if the root path is a directory.
It calls the (err, stats) => {}
callback asynchronously,
when the file system has done its work. We first check for
an error, which would stop further processing and notify
the caller by the way of next(err)
. If everything is OK,
then the following code gets executed:
fs.readdir (rootPath, (err, files) => {
for (let file of files) {
const filePath = path.join (root, ...dirs, file);
fs.lstat (filePath, (err, stats) => {
if (err) {
next (err);
} else {
// ...
}
});
}
});
We read the directory (asynchronously), for every entry
we check (asynchronously) we get its stats
to decide
what to do: it might be a file or a directory.
if (stats.isDirectory ()) {
traverse (root, [...dirs, file], collect, err => {
if (err) {
next (err);
} else {
// ...
}
});
} else {
if (stats.isFile ()) {
collect (dirs, file);
// ...
}
}
If it is a directory, we recursively walk the rest of the
tree (also asynchronously), handling potential errors which
could happen there. If it is a file, we notify the collect()
callback with the current array of subdirectories dirs
and the file
.
But… where are my results?
Running this code will not produce any result, as we did not
call the next()
callback from within traverse()
in case
of success.
But when do we know that we are done? We have to add some
bookkeeping code in our traverse()
function (basically a
counter called pending
which must reach zero when iterating
over the files in the for .. of
loop).
The real implementation
function traverse (root, dirs, collect, next) {
const rootPath = path.join (root, ...dirs);
fs.lstat (rootPath, (err, stats) => {
if (err) {
next (err);
} else {
fs.readdir (rootPath, (err, files) => {
let pending = files.length;
for (let file of files) {
const filePath = path.join (root, ...dirs, file);
fs.lstat (filePath, (err, stats) => {
if (err) {
next (err);
} else {
if (stats.isDirectory ()) {
traverse (root, [...dirs, file], collect, err => {
if (err) {
next (err);
} else {
if (--pending === 0) {
next ();
}
}
});
} else {
if (stats.isFile ()) {
collect (dirs, file);
if (--pending === 0) {
next ();
}
}
}
}
});
}
});
}
});
}
A little bit of refactoring would bring the indentation
level down. Or going async
and await
…
Creating the command line tool
Once the traversal and source code generation were in place,
I wrote the code to parse the command line (basically looking
at process.argv
after skipping the two first arguments).
To make it work as an npm package, there is some more work to do.
Add a "bin"
section in the package.json
file.
"bin": {
"electrum-require-components": "./bin/bin.js"
}
Add a source file ./bin/bin.js
with a UNIX header
The source header is required so that node gets picked up
to execute the file, and not scriptjs
on Microsoft Windows.
#!/usr/bin/env node
require ('../lib/index.js');
Make sure we export ES5
In order to get the package compiled down to ES5, so that it
can be used without Babel once installed, don’t forget to add
a prepublish
action in package.json
:
"scripts": {
"compile": "babel -d lib/ src/",
"prepublish": "npm run compile",
"test": "mocha src/test/**/*.js"
}
Using the command line tool
Now it is time to use our new command line tool inside another project.
To use it, add the package to the devDependencies
section of your
package.json
:
npm install electrum-require-components --save-dev
Then edit package.json
to call the script when compiling. Change this:
"scripts": {
"compile": "babel -d lib/ src/",
"prepublish": "npm run compile"
}
into this:
"scripts": {
"compile": "npm run regen && babel -d lib src",
"prepublish": "npm run compile",
"regen": "electrum-require-components --wrap ./src components .component.js all-components.js"
}
Note: I added the
regen
action to make it more convenient to use, but you could invokeelectrum-require-components
directly instead of usingnpm run regen
.
From now on, I can simply add a new Foo.component.js
file somewhere in
a folder below ./src/components
and it will be picked available through
all-components.js
, like this:
import {Foo} from './all-components.js';