Skip to content

Commit

Permalink
[MAJOR] Provide significantly more control over processing and loading
Browse files Browse the repository at this point in the history
* filter - Used to determine which directories and files to include
* fileTransform - Use as a custom loader or to ignore a file
* dirTransform - Can prevent a dir from being added to the result
  • Loading branch information
Chris Thompson committed Apr 4, 2017
1 parent 1ccb5b5 commit 2f7e770
Show file tree
Hide file tree
Showing 6 changed files with 525 additions and 139 deletions.
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
dir-obj
=======

Create an object from a directory tree. Create a key with an object for each directory by name. All files within a directory that can be required (`.js` or `.json`) are added to the directory object, using the base filename as the key and `require('file.js')` as the value.
Create an object from a directory tree.

`dir-obj` creates an object with keys for each directory (by name).
All files within a directory that can be required (`.js` or `.json`) are added to the directory object, using the base filename as the key and `require('file.js')` as the value.

This is useful for things like loading test fixture data.

## API
You can also customize how `dir-obj` processes the directory tree

```javascript
const fixtures = dirObj.readDirectory(path.join(__dirname, 'fixtures'), {
// Use the `filter` option to filter all directories and files
// Return `true` to include the directory or file (call `file.isDirectory` to check)
// Return `false` to exclude the file or directory from the result
// The default is to only allow `.js` and `.json` files
filter: file => (file.isDirectory || file.isRequirable) && file.name !== 'index.js',
// This function takes a File object and returns the value to be added to the object
// You can mutate `File.key` to change the object key used to reference the value
// You can return `undefined` to not include the file in the result
// The default `fileTransform` just calls `require(file.fullpath)`
// Example: Change whitespace in a filename to `_` then set the value using `require`
fileTransform: file => {
file.key = file.basename.replace(/\s/g, '_');
return require(file.fullpath);
},
// This function takes a File (which is a directory) and a value, which is the recursively
// calculated value of that directory
// Example: Do not include any directories that have no child keys
dirTransform: (file, value) => Object.getOwnPropertyNames(value).length > 0 ? value : undefined
});

## Example
Using the default options:

```javascript
/*
Expand All @@ -31,3 +60,15 @@ describe('test', function () {
});
});
```

Loading SQL files:

```javascript
// Get an object where the value of each key is a string loaded from each SQL file
const fixtures = dirObj.readDirectory(path.resolve('../../models/sql'), {
filter: file => file.isDirectory || file.ext === '.sql'.
fileTransform: file => file.readSync() // helper function equivilent to `fs.readFileSync(file.fullpath, 'utf8')`
});
```
174 changes: 63 additions & 111 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,113 +2,63 @@

const fs = require('fs');
const nodeRequire = require;
const path = require('path');
const jsonFile = /^\.js(?:on)?$/;

/**
* Symbol to define file attributes cache
*
* This allows us to store a cache of file attributes but keep it hidden
*
* @type {Symbol}
* @private
*/
const _attributes = Symbol('attributes');
const _key = Symbol('key');

/**
* File or directory
* @property {String} path The fully resolved path to the file (the parent directory)
* @property {String} fullpath The fully resolved path of the file
* @property {String} ext The file extension
* @property {String} name The full name of the file with no path
* @property {String} basename The name of the file without the extension
* @property {Boolean} isDirectory Returns `true` if the file is a directory
*/
class File {
constructor(dir, file) {
this.path = path.resolve(dir);
this.fullpath = path.join(this.path, file);
this.ext = path.extname(this.fullpath);
this.name = path.basename(this.fullpath);
this.basename = path.basename(this.name, this.ext);
};

/**
* Get a string value to be used as an object key
*
* @returns {String}
*/
get key() {
return this.attributes.key;
}

set key(value) {
return (this.attributes.key = value);
}

/**
* Get the file's attributes
*
* @see https://nodejs.org/api/fs.html#fs_fs_lstatsync_path
* @returns {Object}
*/
get attributes() {
if (!this[_attributes]) {
const a = this[_attributes] = fs.lstatSync(this.fullpath);
a.key = (a.isDirectory()) ? this.name : this.basename;
}
return this[_attributes];
};

/**
* Check if file is a directory
*
* @returns {Boolean}
*/
get isDirectory() {
return this.attributes.isDirectory();
}

/**
* Check if file extension is either `.js` or `.json`
*
* @returns {Boolean}
*/
get isRequirable() {
return jsonFile.test(this.ext);
}
}
const File = require('./lib/file');

/**
* Add a property to an object with a value
*
* @param {Function} transform
* A transform function that takes the value and the file
* @param {Object} object
* @param {*} value
* @param {File} file
* @param {Function} transform A transform function that takes the value and the file
* If `transform` returns a function, then the object will use it as a getter
*
*/
function defineProperty(object, value, file, transform) {
function defineProperty (object, file, transform, value) {
const property = { enumerable: true };
const result = transform(value, file);
// If dirTransform returns a function, then use it as a getter, otherwise just return the value
property[(typeof result === 'function') ? 'get' : 'value'] = result;

return Object.defineProperty(object, file.key || file.basename, property);
const result = transform(file, value);
if (result !== undefined) {
// If dirTransform returns a function, then use it as a getter, otherwise just return the value
property[( result instanceof Function ) ? 'get' : 'value'] = result;
return Object.defineProperty(object, file.key || file.basename, property);
}
}

/**
* Make sure transform functions are defined
*
* @param {Object} [options]
* @param {RegExp|Function} [options.filter]
* @param {Function} [options.dirTransform]
* @param {Function} [options.fileTransform]
*/
function defaultOptions(options) {
const opts = options || {};
if (typeof opts.dirTransform !== 'function') {
opts.dirTransform = v => v;
function defaultOptions (options) {
const opts = {};

if (!(options instanceof Object)) {
options = {};
}

if (typeof opts.fileTransform !== 'function') {
opts.fileTransform = v => v;
if (options.filter instanceof RegExp) {
opts.filter = file => opts.filter.test(file.basename);
} else if (options.filter instanceof Function) {
opts.filter = options.filter;
} else {
opts.filter = file => (
file.isDirectory || file.isRequirable
);
}

if (options.dirTransform instanceof Function) {
opts.dirTransform = options.dirTransform;
} else {
opts.dirTransform = (f, v) => v;
}

if (options.fileTransform instanceof Function) {
opts.fileTransform = options.fileTransform;
} else {
opts.fileTransform = f => nodeRequire(f.fullpath);
}

return opts;
Expand All @@ -119,62 +69,64 @@ function defaultOptions(options) {
*
* @param {String} dir The directory to read. Can be relative or absolute.
* @param {Object} [options]
* @param {Boolean} [options.dirTransform=Object.freeze] A function called with the object result from scanning a directory
* @param {Function} [options.fileTransform=_.cloneDeep] A function called with one argument that is the result of require(file)
* @param {Boolean} [options.dirTransform] A function called with the object result from scanning a directory
* @param {Function} [options.fileTransform] A function called with one argument that is the result of require(file)
* @returns {Promise.<Object>}
*/
function readDirectoryAsync(dir, options) {
function readDirectoryAsync (dir, options) {
const opts = defaultOptions(options);

return new Promise((resolve, reject) => {
fs.readdir(dir, (err, files) => {
if (err) { reject(err); }
if (!Array.isArray(files)) { resolve({}); }
if (err) { return reject(err); }
if (!Array.isArray(files)) { return resolve({}); }

let promises = [];
const result = files.reduce((accum, name) => {
const file = new File(dir, name);
if (file.isDirectory) {
promises.push(readDirectory(file.fullpath, options)
.then(obj => defineProperty(accum, obj, file, opts.dirTransform)));
} else if (file.isRequirable) {
defineProperty(accum, require(file.fullpath), file, opts.fileTransform);
if (opts.filter(file)) {
if (file.isDirectory) {
promises.push(readDirectoryAsync(file.fullpath, options)
.then(obj => defineProperty(accum, file, opts.dirTransform, obj)));
} else {
defineProperty(accum, file, opts.fileTransform);
}
}
return accum;
}, {});

Promise.all(promises).then(() => resolve(result));
});
})
});
}

/**
* Read a directory tree and return a object
*
* @param {String} dir The directory to read. Can be relative or absolute.
* @param {Object} [options]
* @param {Boolean} [options.dirTransform] A function called with the object result from scanning a directory
* Default is no transform
* @param {Function} [options.dirTransform] A function called with the object result from scanning a directory
* @param {Function} [options.fileTransform] A function called with one argument that is the result of require(file)
* Default is a getter function that returns a clone of the file
* @returns {Object}
*
* @example
* readDirectory('./fixtures', { dirTransform: Object.freeze, fileTransform: v => () => _.cloneDeep(v) })
* .then(console.log)
*/
function readDirectory(dir, options) {
function readDirectory (dir, options) {
const opts = defaultOptions(options);

const files = fs.readdirSync(dir);
if (!Array.isArray(files)) { return {}; }

return files.reduce((accum, name) => {
const file = new File(dir, name);
if (file.isDirectory) { // Recurse into sub-directories
defineProperty(accum, readDirectory(file.fullpath, options), file, opts.dirTransform);
} else if (file.isRequirable) {
defineProperty(accum, nodeRequire(file.fullpath), file, opts.fileTransform);
if (opts.filter(file)) {
if (file.isDirectory) { // Recurse into sub-directories
defineProperty(accum, file, opts.dirTransform, readDirectory(file.fullpath, options));
} else {
defineProperty(accum, file, opts.fileTransform);
}
}
return accum;
}, {});
Expand Down
Loading

0 comments on commit 2f7e770

Please sign in to comment.