diff --git a/package.json b/package.json index 1834f5a..f8c0e7f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "dependencies": { "bluebird": "^3.3.1", "globby": "^4.0.0", - "handlebars": "^4.0.5" + "handlebars": "^4.0.5", + "js-yaml": "^3.5.3" }, "devDependencies": { "babel-cli": "^6.5.1", diff --git a/src/index.js b/src/index.js index 9049076..fc8f75e 100644 --- a/src/index.js +++ b/src/index.js @@ -2,33 +2,27 @@ var parseOptions = require('./options'); var prepareTemplates = require('./template').prepareTemplates; var utils = require('./utils'); - +/** + * Build a data/context object for use by the builder + * @TODO This may move into its own module if it seems appropriate + * + * @param {Object} options + * @return {Promise} resolving to {Object} of keyed file data + */ +function prepareData (options) { + // Data data + return utils.readFilesKeyed(options.data.src, { + contentFn: options.data.parseFn + }); +} /** * Build the drizzle output * - * @return {Promise}; resolves to options {object} (for now) + * @return {Promise}; resolves to [dataObj, Handlebars] for now */ function drizzle (options) { const opts = parseOptions(options); - - // const buildData = new Object(); - // const readLayouts = utils.readFilesKeyed(opts.templates.layouts) - // .then(fileData => buildData.layouts = fileData); - // const readDocs = utils.readFilesKeyed(opts.docs) - // .then(fileData => { - // for (var file in fileData) { - // fileData[file].name = utils.toTitleCase(file); - // fileData[file].content = 'todo'; // markdown file.content - // } - // return fileData; - // }); - // const readData = utils.readFilesKeyed(opts.data).then(fileData => { - // for (var file in fileData) { - // fileData[file].contents = 'todo'; // yaml load contents - // } - // return fileData; - // }); - return prepareTemplates(opts).then(handlebars => opts); + return Promise.all([prepareData(opts), prepareTemplates(opts)]); } export default drizzle; diff --git a/src/options.js b/src/options.js index ce0cb73..eb5400c 100644 --- a/src/options.js +++ b/src/options.js @@ -1,7 +1,12 @@ import Handlebars from 'handlebars'; +import yaml from 'js-yaml'; import { merge } from './utils'; const defaults = { + data: { + src: ['src/data/**/*.yaml'], + parseFn: (contents, path) => yaml.safeLoad(contents) + }, templates: { handlebars: Handlebars, helpers : {}, @@ -30,8 +35,8 @@ function mergeDefaults (options = {}) { * @return {object} User options */ function translateOptions (options = {}) { - /* eslint-disable prefer-const */ const { + data, handlebars, helpers, layouts, @@ -48,8 +53,19 @@ function translateOptions (options = {}) { partials } }; + // @TODO: Is there are more concise way of handling this? + // If you use the pattern above, an object value for `data` + // will get improperly nested/trounced + if (data) { + if (typeof data === 'string') { + result.data = { + src: data + }; + } else { + result.data = data; + } + } return result; - /* eslint-enable prefer-const */ } const parseOptions = options => mergeDefaults(translateOptions(options)); diff --git a/src/utils.js b/src/utils.js index f3f7194..b4fad9a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -5,11 +5,45 @@ import {readFile as readFileCB} from 'fs'; var readFile = Promise.promisify(readFileCB); /* Helper functions */ -const basename = filepath => path.basename(filepath, path.extname(filepath)); -const dirname = filepath => path.normalize(path.dirname(filepath)); -const parentDirname = filepath => dirname(filepath).split(path.sep).pop(); -const removeNumbers = str => str.replace(/^[0-9|\.\-]+/, ''); -const getFiles = glob => globby(glob, {nodir: true }); + +/** + * Return extension-less basename of filepath + * @param {String} filepath + * @example basename('foo/bar/baz.txt'); // -> 'baz' + */ +function basename (filepath) { + return path.basename(filepath, path.extname(filepath)); +} + +/** + * Return normalized (no '..', '.') full dirname of filepath + * @param {String} filepath + * @example dirname('../ding/foo.txt'); // -> '/Users/shiela/ding/' + */ +function dirname (filepath) { + return path.normalize(path.dirname(filepath)); +} + +/** + * Return the name of this files immediate parent directory + * @param {String} filepath + * @example basename('foo/bar/baz.txt'); // -> 'bar' + */ +function parentDirname (filepath) { + return dirname(filepath).split(path.sep).pop(); +} + + +function removeLeadingNumbers (str) { + return str.replace(/^[0-9|\.\-]+/, ''); +} +/** + * @param {glob} glob + * @return {Promise} resolving to {Array} of files matching glob + */ +function getFiles (glob) { + return globby(glob, { nodir: true }); +} /** * Utility function to test if a value COULD be a glob. A single string or @@ -28,18 +62,31 @@ function isGlob (candidate) { } /** - * Take a glob; read the files. Return a Promise that ultimately resolves - * to an Array of objects: - * [{ path: original filepath, - * contents: utf-8 file contents}...] + * Take a glob; read the files, optionally running a `contentFn` over + * the contents of the file. + * + * @param {glob} glob of files to read + * @param {Object} Options: + * - {Function} contentFn(content, path): optional function to run over content + * in files; defaults to a no-op + * - {String} encoding + * + * @return {Promise} resolving to Array of Objects: + * - {String} path + * - {String || Mixed} contents: contents of file after contentFn */ -function readFiles (glob) { +function readFiles (glob, { + contentFn = (content, path) => content, + encoding = 'utf-8' +} = {}) { return getFiles(glob).then(paths => { - var fileReadPromises = paths.map(path => { - return readFile(path, 'utf-8') - .then(contents => ({ path, contents })); - }); - return Promise.all(fileReadPromises); + return Promise.all(paths.map(path => { + return readFile(path, encoding) + .then(contents => { + contents = contentFn(contents, path); + return { path, contents }; + }); + })); }); } @@ -47,14 +94,24 @@ function readFiles (glob) { * Read the files from a glob, but then instead of resolving the * Promise with an Array of objects (@see readFiles), resolve with a * single object; each file's contents is keyed by its filename run - * through keyname(). + * through optional `keyFn(filePath, options)`` (default: keyname). + * Will pass other options on to readFiles and keyFn * + * @param {glob} + * @param {Object} options (all optional): + * - keyFn + * - contentFn + * - stripNumbers + * @return {Promise} resolving to {Object} of keyed file contents */ -function readFilesKeyed (glob, preserveNumbers = false) { - return readFiles(glob).then(allFileData => { +function readFilesKeyed (glob, options = {}) { + const { + keyFn = (path, options) => keyname(path, options) + } = options; + return readFiles(glob, options).then(allFileData => { const keyedFileData = new Object(); for (var aFile of allFileData) { - keyedFileData[keyname(aFile.path, preserveNumbers)] = aFile.contents; + keyedFileData[keyFn(aFile.path, options)] = aFile.contents; } return keyedFileData; }); @@ -65,15 +122,15 @@ function readFilesKeyed (glob, preserveNumbers = false) { * partials, etc, based on a filepath: * - replace whitespace characters with `-` * - use only the basename, no extension - * - unless preserveNumbers, remove numbers from the string as well + * - unless stripNumbers option false, remove numbers from the string as well * * @param {String} str filepath - * @param {Boolean} preserveNumbers + * @param {Object} options * @return {String} */ -function keyname (str, preserveNumbers = false) { +function keyname (str, { stripNumbers = true } = {}) { const name = basename(str).replace(/\s/g, '-'); - return (preserveNumbers) ? name : removeNumbers(name); + return (stripNumbers) ? removeLeadingNumbers(name) : name; } /** diff --git a/test/fixtures/data/05-another-data.yaml b/test/fixtures/data/05-another-data.yaml new file mode 100644 index 0000000..b3880c9 --- /dev/null +++ b/test/fixtures/data/05-another-data.yaml @@ -0,0 +1,8 @@ +ding: + - dong + - dell + - 'cat is in the well' + - 5 +forestry: + fob: 'key' + bork: 'bing' diff --git a/test/fixtures/data/data-as-json.json b/test/fixtures/data/data-as-json.json new file mode 100644 index 0000000..2f97f6f --- /dev/null +++ b/test/fixtures/data/data-as-json.json @@ -0,0 +1,4 @@ +{ + "foo" : "bar", + "fortunately": 5 +} diff --git a/test/fixtures/data/sample-data.yaml b/test/fixtures/data/sample-data.yaml new file mode 100644 index 0000000..a3b49d9 --- /dev/null +++ b/test/fixtures/data/sample-data.yaml @@ -0,0 +1,4 @@ +foo: + - bar + - baz +elfin: 'small things' diff --git a/test/index.js b/test/index.js index c006ff1..b4d5bef 100644 --- a/test/index.js +++ b/test/index.js @@ -2,20 +2,24 @@ var chai = require('chai'); var expect = chai.expect; var builder = require('../dist/'); +var path = require('path'); -const options = { - templates: { - partials: `${__dirname}/fixtures/partials/*`, - helpers: `${__dirname}/fixtures/helpers/*.js` - } -}; describe ('drizzle builder integration', () => { - it ('should return opts used for building', () => { - builder(options).then(opts => { - expect(opts).to.be.an.object; - expect(opts.templates).to.be.an.object; - expect(opts.templates.handlebars).to.be.an.object; + const options = { + data: { + src: path.join(__dirname, 'fixtures/data/*.yaml') + }, + templates: { + helpers: path.join(__dirname, 'fixtures/helpers/**/*.js'), + partials: path.join(__dirname, 'fixtures/partials/*.hbs') + } + }; + it ('should return data and context', done => { + builder(options).then(drizzleData => { + expect(drizzleData[0]).to.contain.keys('another-data', 'sample-data'); + expect(drizzleData[0]['another-data']).to.be.an('object'); + done(); }); }); }); diff --git a/test/options.js b/test/options.js index 26c8467..e904cf6 100644 --- a/test/options.js +++ b/test/options.js @@ -23,10 +23,14 @@ describe ('drizzle-builder', () => { }); it ('should translate template options', () => { var opts = parseOptions({ + data: 'foo/bar/baz.yml', layoutIncludes: 'a path', views: 'a path to views' }); expect(opts).to.be.an('object'); + expect(opts.data).to.be.an('object'); + expect(opts.data.src).to.be.a('string'); + expect(opts.data.src).to.equal('foo/bar/baz.yml'); expect(opts.views).not.to.be; expect(opts.layoutIncludes).not.to.be; expect(opts.templates).to.be.an('object'); @@ -41,11 +45,13 @@ describe ('drizzle-builder', () => { it ('should provide default templating options', () => { var opts = parseOptions(); - expect(opts).to.contain.keys('templates'); + expect(opts).to.contain.keys('templates', 'data'); expect(opts.templates).to.be.an('object'); expect(opts.templates).to.have.keys('handlebars', 'helpers', 'layouts', 'pages', 'partials'); expect(opts.templates.handlebars).to.be.an('object'); + expect(opts.data).to.have.keys('src', 'parseFn'); + expect(opts.data.parseFn).to.be.a('function'); }); }); }); diff --git a/test/utils.js b/test/utils.js index 8c2e705..65d51a2 100644 --- a/test/utils.js +++ b/test/utils.js @@ -56,6 +56,21 @@ describe ('utils', () => { expect(badGlobs.every(glob => utils.isGlob(glob))).to.be.false; }); }); + describe('keyname', () => { + it ('should strip leading numbers by default', () => { + var result = utils.keyname('foo/01-bar.baz'); + expect(result).not.to.contain('01-'); + }); + it ('should strip parent directories and extensions', () => { + var result = utils.keyname('foo/01-bar.baz'); + expect(result).not.to.contain('foo'); + expect(result).not.to.contain('baz'); + }); + it ('should accept option to retain leading numbers', () => { + var result = utils.keyname('foo/01-bar.baz', { stripNumbers: false }); + expect(result).to.contain('01-'); + }); + }); describe('readFiles', () => { it ('should read files from a glob', done => { var glob = path.join(__dirname, 'fixtures/helpers/*.js'); @@ -65,7 +80,18 @@ describe ('utils', () => { done(); }); }); - it ('should be able to key files by getName', done => { + it ('should run passed function over content', done => { + var glob = path.join(__dirname, 'fixtures/helpers/*.js'); + utils.readFiles(glob, { contentFn: (content, path) => 'foo' }) + .then(allFileData => { + expect(allFileData).to.have.length.of(3); + expect(allFileData[0].contents).to.equal('foo'); + done(); + }); + }); + }); + describe('readFilesKeyed', () => { + it ('should be able to key files by keyname', done => { var glob = path.join(__dirname, 'fixtures/helpers/*.js'); utils.readFilesKeyed(glob).then(allFileData => { expect(allFileData).to.be.an('object'); @@ -73,6 +99,34 @@ describe ('utils', () => { done(); }); }); + it ('should accept an option to preserve leading numbers', done => { + var glob = path.join(__dirname, 'fixtures/data/*.yaml'); + utils.readFilesKeyed(glob, { stripNumbers: false }).then(allFileData => { + expect(allFileData).to.be.an('object'); + done(); + }); + }); + it ('should accept a function to derive keys', done => { + var glob = path.join(__dirname, 'fixtures/data/*.yaml'); + utils.readFilesKeyed(glob, { keyFn: (path, options) => 'foo' + path }) + .then(allFileData => { + expect(Object.keys(allFileData)[0]).to.contain('foo'); + done(); + }); + }); + it ('should pass contentFn through to readFiles', done => { + var glob = path.join(__dirname, 'fixtures/data/*.yaml'); + utils.readFilesKeyed(glob, { + keyFn: (path, options) => 'foo' + path, + contentFn: (content, path) => 'foo' + }).then(allFileData => { + for (var fileKey in allFileData) { + expect(fileKey).to.contain('foo'); + expect(allFileData[fileKey]).to.equal('foo'); + } + done(); + }); + }); }); describe('parent directory (parentDirname)', () => { it ('should derive correct parent dirname of files', () => { @@ -82,7 +136,7 @@ describe ('utils', () => { }); }); describe('merge()', () => { - it ('works', () => { + it ('merges objects correctly', () => { var actual = utils.merge( {a: 1, c: {d: 3}}, {a: 2, b: 1, c: {e: 4}}