UNPKG

load-templates

Version:
708 lines (580 loc) 16 kB
/*! * load-templates <https://github.com/jonschlinkert/load-templates> * * Copyright (c) 2014 Jon Schlinkert, contributors. * Licensed under the MIT License */ 'use strict'; // process.env.DEBUG = 'load-templates'; var fs = require('fs'); var arr = require('arr'); var path = require('path'); var util = require('util'); var File = require('vinyl'); var debug = require('debug')('load-templates'); var hasAny = require('has-any'); var Options = require('option-cache'); var hasAnyDeep = require('has-any-deep'); var mapFiles = require('map-files'); var matter = require('gray-matter'); var omit = require('omit-keys'); var omitEmpty = require('omit-empty'); var reduce = require('reduce-object'); var typeOf = require('kind-of'); var utils = require('./lib/utils'); /** * Initialize a new `Loader` * * ```js * var loader = new Loader(); * ``` * * @class Loader * @param {Object} `obj` Optionally pass an `options` object to initialize with. * @api public */ function Loader(options) { Options.call(this, options); } /** * Inherit `Options` */ util.inherits(Loader, Options); /** * Rename the `key` of a template object, often a file path. By * default the key is just passed through unchanged. * * Pass a custom `renameKey` function on the options to change * how keys are renamed. * * @param {String} `key` * @param {Object} `options` * @return {Object} */ Loader.prototype.renameKey = function(key, options) { debug('renaming key:', key); var opts = merge({}, this.options, options); if (opts.renameKey) { return opts.renameKey(key, omit(opts, 'renameKey')); } return key; }; /** * Default function for reading any files resolved. * * Pass a custom `readFn` function on the options to change * how files are read. * * @param {String} `filepath` * @param {Object} `options` * @return {Object} */ Loader.prototype.readFn = function(filepath, options) { debug('reading:', filepath); var opts = merge({ enc: 'utf8' }, this.options, options); if (opts.readFn) { return opts.readFn(filepath, omit(opts, 'readFn')); } return fs.readFileSync(filepath, opts.enc); }; /** * Return an object of files. Pass a custom `mapFiles` function * to change behavior. * * @param {String} `patterns` * @param {Object} `options` * @return {Object} */ Loader.prototype.mapFiles = function(patterns, locals, options) { debug('mapping files:', patterns); var opts = merge({}, this.options, options); if (opts.mapFiles) { return opts.mapFiles(patterns, locals, omit(opts, 'mapFiles')); } return mapFiles(patterns, { name: this.renameKey, read: this.readFn }); }; /** * Default function for parsing any files resolved. * * Pass a custom `parseFn` function on the options to change * how files are parsed. * * @param {String} `filepath` * @param {Object} `options` * @return {Object} */ Loader.prototype.parseFn = function(str, options) { debug('parsing:', str); var opts = merge({ autodetect: true }, this.options, options); if (opts.noparse === true) { return str; } if (opts.parseFn) { return opts.parseFn(str, omit(opts, 'parseFn')); } return matter(str, omit(options, ['delims'])); }; /** * Unless a custom parse function is passed, by default YAML * front matter is parsed from the string in the `content` * property. * * @param {Object} `value` * @param {Object} `options` * @return {Object} */ Loader.prototype.parseContent = function(obj, options) { debug('parsing content property', obj); var copy = omit(obj, ['content']); var o = {}; if (isString(o.content) && !hasOwn(o, 'orig')) { var orig = o.content; o = this.parseFn(o.content, options); o.orig = orig; } merge(o, copy); o._parsed = true; return o; }; /** * Map files resolved from glob patterns or file paths. * * * @param {String|Array} `patterns` * @param {Object} `options` * @return {Object} */ Loader.prototype.parseFiles = function(patterns, locals, options) { debug('mapping files:', patterns); var files = this.mapFiles(patterns, locals, options); return reduce(files, function (acc, value, key) { debug('reducing file: %s', key, value); if (isString(value)) { value = this.parseFn(value); value.path = value.path || key; } value._parsed = true; value._mappedFile = true; acc[key] = value; return acc; }.bind(this), {}); }; /** * First arg is a file path or glob pattern. * * ```js * loader('a/b/c.md', ...); * loader('a/b/*.md', ...); * ``` * * @param {String} `key` * @param {Object} `value` * @return {Object} */ Loader.prototype.normalizeFiles = function(patterns, locals, options) { debug('normalizing patterns: %s', patterns); options = options || {}; locals = locals || {}; merge(locals, locals.locals, options.locals); merge(options, locals.options); var files = this.parseFiles(patterns, locals, options); if (files && Object.keys(files).length === 0) { return null; } return reduce(files, function (acc, value, key) { debug('reducing normalized file: %s', key); value.options = utils.flattenOptions(options); value.locals = utils.flattenLocals(locals); acc[key] = value; return acc; }, {}); }; /** * When the first arg is an array, assume it's glob * patterns or file paths. * * ```js * loader(['a/b/c.md', 'a/b/*.md']); * loader(['a/b/c.md', 'a/b/*.md'], {a: 'b'}, {foo: true}); * ``` * * @param {Object} `patterns` Template object * @param {Object} `locals` Possibly locals, with `options` property * @return {Object} `options` Possibly options */ Loader.prototype.normalizeArray = function(patterns, locals, options) { debug('normalizing array:', patterns); return this.normalizeFiles(patterns, locals, options); }; /** * First value is a string, second value is a string or * an object. * * {%= docs("dev-normalize-string") %} * * @param {Object} `value` Always an object. * @param {Object} `locals` Always an object. * @param {Object} `options` Always an object. * @return {Object} Returns a normalized object. */ Loader.prototype.normalizeString = function(key, value, locals, options) { debug('normalizing string: %s', key, value); var args = [].slice.call(arguments, 1); var objects = arr.objects(arguments); var props = utils.siftProps.apply(this, args); var opts = options || props.options; var locs = props.locals; var files; var root = {}; var opt = {}; var o = {}; o[key] = {}; // If only `value` is defined if (value == null) { // check if `key` is a file path files = this.normalizeFiles(key); if (files != null) { return files; // if not, add a heuristic } else { // If it's a glob pattern, this means it didn't expand // so return an empty object. if (/[*{}()]/.test(key)) { return {}; } o[key]._hasPath = true; o[key].path = o[key].path || key; return o; } } if ((value && isObject(value)) || objects == null) { debug('[value] s1o1: %s, %j', key, value); files = this.normalizeFiles(key, value, locals, options); if (files != null) { return files; } else { debug('[value] s1o2: %s, %j', key, value); root = utils.pickRoot(value); var loc = {}; opt = {}; merge(loc, utils.pickLocals(value)); merge(loc, locals); merge(root, utils.pickRoot(loc)); merge(opt, loc.options); merge(opt, value.options); merge(opt, options); merge(root, utils.pickRoot(opt)); o[key] = root; o[key].locals = loc; o[key].options = opt; var content = value && value.content; if (o[key].content == null && content != null) { o[key].content = content; } } } if (hasOwn(opt, '_hasPath') && opt._hasPath === false) { o[key].path = null; } else { o[key].path = value.path || key; } if (value && isString(value)) { debug('[value] string: %s, %s', key, value); root = utils.pickRoot(locals); o[key] = root; o[key].content = value; o[key].path = o[key].path = key; o[key]._s1s2 = true; if (objects == null) { return o; } } if (locals && isObject(locals)) { debug('[value] string: %s, %s', key, value); merge(locs, locals.locals); merge(opts, locals.options); o[key]._s1s2o1 = true; } if (options && isObject(options)) { debug('[value] string: %s, %s', key, value); merge(opts, options); o[key]._s1s2o1o2 = true; } opt = utils.flattenOptions(opts); merge(opt, o[key].options); o[key].options = opt; locs = omit(locs, 'options'); o[key].locals = utils.flattenLocals(locs); return o; }; /** * Normalize objects that have `rootKeys` directly on * the root of the object. * * **Example** * * ```js * {path: 'a/b/c.md', content: 'this is content.'} * ``` * * @param {Object} `value` Always an object. * @param {Object} `locals` Always an object. * @param {Object} `options` Always an object. * @return {Object} Returns a normalized object. */ Loader.prototype.normalizeShallowObject = function(value, locals, options) { debug('normalizing shallow object: %j', value); var o = utils.siftLocals(value); o.options = merge({}, options, o.options); o.locals = merge({}, locals, o.locals); return o; }; /** * Normalize nested templates that have the following pattern: * * ```js * { 'a/b/a.md': {path: 'a/b/a.md', content: 'this is content.'}, * 'a/b/b.md': {path: 'a/b/b.md', content: 'this is content.'}, * 'a/b/c.md': {path: 'a/b/c.md', content: 'this is content.'} } *``` */ Loader.prototype.normalizeDeepObject = function(obj, locals, options) { debug('normalizing deep object: %j', obj); return reduce(obj, function (acc, value, key) { acc[key] = this.normalizeShallowObject(value, locals, options); return acc; }.bind(this), {}); }; /** * When the first arg is an object, all arguments * should be objects. The only exception is when * the last arg is a fucntion. * * ```js * loader({'a/b/c.md', ...}); * * // or * loader({path: 'a/b/c.md', ...}); * ``` * * @param {Object} `object` Template object * @param {Object} `locals` Possibly locals, with `options` property * @return {Object} `options` Possibly options */ Loader.prototype.normalizeObject = function(o) { debug('normalizing object: %j', o); var args = [].slice.call(arguments); var locals1 = utils.pickLocals(args[1]); var locals2 = utils.pickLocals(args[2]); var val; var opts = args.length === 3 ? locals2 : {}; if (hasAny(o, ['path', 'content'])) { val = this.normalizeShallowObject(o, locals1, opts); return createKeyFromPath(val.path, val); } if (hasAnyDeep(o, ['path', 'content'])) { val = this.normalizeDeepObject(o, locals1, opts); return createPathFromStringKey(val); } throw new Error('Invalid template object. Must' + 'have a `path` or `content` property.'); }; /** * When the first arg is an array, assume it's glob * patterns or file paths. * * ```js * loader(['a/b/c.md', 'a/b/*.md']); * ``` * * @param {Object} `patterns` Template object * @param {Object} `locals` Possibly locals, with `options` property * @return {Object} `options` Possibly options */ Loader.prototype.normalizeFunction = function(fn) { debug('normalizing fn:', arguments); return fn.apply(this, arguments); }; /** * Select the template normalization function to start * with based on the first argument passed. */ Loader.prototype._format = function() { var args = [].slice.call(arguments); debug('normalize format', args); switch (typeOf(args[0])) { case 'array': return this.normalizeArray.apply(this, args); case 'string': return this.normalizeString.apply(this, args); case 'object': return this.normalizeObject.apply(this, args); case 'function': return this.normalizeFunction.apply(this, args); default: return {}; } }; /** * Final normalization step to remove empty values and rename * the object key. By now the template should be _mostly_ * loaderd. * * @param {Object} `object` Template object * @return {Object} */ Loader.prototype.load = function() { debug('loader', options); var tmpl = this._format.apply(this, arguments); var options = this.options || {}; return reduce(tmpl, function (acc, value, key) { if (value && Object.keys(value).length === 0) { return acc; } // Normalize the template this.normalize(options, acc, value, key); return acc; }.bind(this), {}); }; /** * Base normalize method, abstracted to make it easier to * pass in custom methods. * * @param {Object} `options` * @param {Object} `acc` * @param {String|Object} `value` * @param {String} `key` * @return {Object} Normalized template object. */ Loader.prototype.normalize = function (options, acc, value, key) { debug('normalize: %s, %value', key); if (options && options.normalize) { return options.normalize(acc, value, key); } value.ext = value.ext || path.extname(value.path); var parsed = this.parseContent(value, options); merge(value, parsed); // Cleanup value = cleanupProps(value, options); value.content = value.content || null; // Create a vinyl file? if (options && options.vinyl) { value = toVinyl(value); } // Rename the object key acc[this.renameKey(key, options)] = value; return acc; }; /** * When `options.vinyl` is true, transform the value to * a vinyl file. * * @param {Object} `value` * @return {Object} Returns a vinyl file. * @api private */ function toVinyl(value) { return new File({ contents: new Buffer(value.content), path: value.path, locals: value.locals || {}, options: value.options || {} }); } /** * Clean up some properties before return the final * normalized template object. * * @param {Object} `template` * @param {Object} `options` * @return {Object} */ function cleanupProps(template, options) { if (template.content === template.orig) { template = omit(template, 'orig'); } if (options.debug == null) { template = omit(template, utils.heuristics); } return omitEmpty(template); } /** * Create a `path` property from the template object's key. * * If we detected a `path` property directly on the object that was * passed, this means that the object is not formatted as a key/value * pair the way we want our normalized templates. * * ```js * // before * loader({path: 'a/b/c.md', content: 'this is foo'}); * * // after * loader('a/b/c.md': {path: 'a/b/c.md', content: 'this is foo'}); * ``` * * @param {String} `filepath` * @param {Object} `value` * @return {Object} * @api private */ function createKeyFromPath(filepath, value) { var o = {}; o[filepath] = value; return o; } /** * Create the `path` property from the string * passed in the first arg. This is only used * when the second arg is a string. * * ```js * loader('abc', {content: 'this is content'}); * //=> normalize('abc', {path: 'abc', content: 'this is content'}); * ``` * * @param {Object} `obj` * @return {Object} */ function createPathFromStringKey(o) { for (var key in o) { if (hasOwn(o, key)) { o[key].path = o[key].path || key; } } return o; } /** * Merge util. This is temporarily until benchmarks are done * so we can easily swap in a different function. * * @param {Object} `obj` * @return {Object} * @api private */ function merge() { return utils.extend.apply(utils.extend, arguments); } /** * Utilities for returning the native `typeof` a value. * * @api private */ function isString(val) { return typeOf(val) === 'string'; } function isObject(val) { return typeOf(val) === 'object'; } function hasOwn(o, prop) { return {}.hasOwnProperty.call(o, prop); } /** * Expose `loader` * * @type {Object} */ module.exports = Loader;