'use strict' const assert = require('assert') const matter = require('gray-matter') const Mode = require('stat-mode') const path = require('path') const { readdir, batchAsync, isFunction, outputFile, stat, readFile, writeStream, rm, isString, isBoolean, isObject, isNumber, isUndefined, match } = require('./helpers') const utf8 = require('is-utf8') const Ware = require('ware') const { Debugger, fileLogHandler } = require('./debug') const symbol = { env: Symbol('env'), log: Symbol('log') } /** * Metalsmith representation of the files in `metalsmith.source()`. * The keys represent the file paths and the values are {@link File} objects * @typedef {Object.} Files */ /** * Metalsmith file. Defines `mode`, `stats` and `contents` properties by default, but may be altered by plugins * * @typedef File * @property {Buffer} contents - A NodeJS [buffer](https://nodejs.org/api/buffer.html) that can be `.toString`'ed to obtain its human-readable contents * @property {import('fs').Stats} stats - A NodeJS [fs.Stats object](https://nodejs.org/api/fs.html#fs_class_fs_stats) object with extra filesystem metadata and methods * @property {string} mode - Octal permission mode, see https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation */ /** * A callback to run when the Metalsmith build is done * * @callback BuildCallback * @param {Error} [error] * @param {Files} files * @this {Metalsmith} * * @example * function onBuildEnd(error, files) { * if (error) throw error * console.log('Build success') * } */ /** * A callback to indicate that a plugin's work is done * * @callback DoneCallback * @param {Error} [error] * * @example * function plugin(files, metalsmith, done) { * // ..do stuff * done() * } */ /** * A Metalsmith plugin is a function that is passed the file list, the metalsmith instance, and a `done` callback. * Calling the callback is required for asynchronous plugins, and optional for synchronous plugins. * * @callback Plugin * @param {Files} files * @param {Metalsmith} metalsmith * @param {DoneCallback} done * * @example * function drafts(files, metalsmith) { * Object.keys(files).forEach(path => { * if (files[path].draft) { * delete files[path] * } * }) * } * * metalsmith.use(drafts) */ /** * Export `Metalsmith`. */ module.exports = Metalsmith /** * Initialize a new `Metalsmith` builder with a working `directory`. * * @callback Metalsmith * @param {string} directory * @property {Plugin[]} plugins * @property {string[]} ignores * @return {Metalsmith} */ function Metalsmith(directory) { if (!(this instanceof Metalsmith)) return new Metalsmith(directory) assert(directory, 'You must pass a working directory path.') this.plugins = [] this.ignores = [] this.directory(directory) this.metadata({}) this.source('src') this.destination('build') this.concurrency(Infinity) this.clean(true) this.frontmatter(true) Object.defineProperty(this, symbol.env, { value: Object.create(null), enumerable: false }) Object.defineProperty(this, symbol.log, { value: null, enumerable: false, writable: true }) } /** * Add a `plugin` function to the stack. * @param {Plugin} plugin * @return {Metalsmith} * * @example * metalsmith * .use(drafts()) // use the drafts plugin * .use(markdown()) // use the markdown plugin */ Metalsmith.prototype.use = function (plugin) { this.plugins.push(plugin) return this } /** * Get or set the working `directory`. * * @param {string} [directory] * @return {string|Metalsmith} * * @example * new Metalsmith('.') // set the path of the working directory through the constructor * metalsmith.directory() // returns '.' * metalsmith.directory('./other/path') // set the path of the working directory */ Metalsmith.prototype.directory = function (directory) { if (!arguments.length) return path.resolve(this._directory) assert(isString(directory), 'You must pass a directory path string.') this._directory = directory return this } /** * Get or set the global `metadata`. * * @param {Object} [metadata] * @return {Object|Metalsmith} * * @example * metalsmith.metadata({ sitename: 'My blog' }); // set metadata * metalsmith.metadata() // returns { sitename: 'My blog' } */ Metalsmith.prototype.metadata = function (metadata) { if (isUndefined(metadata)) return this._metadata assert(isObject(metadata), 'You must pass a metadata object.') this._metadata = Object.assign(this._metadata || {}, metadata) return this } /** * Get or set the source directory. * * @param {string} [path] * @return {string|Metalsmith} * * @example * metalsmith.source('./src'); // set source directory * metalsmith.source() // returns './src' */ Metalsmith.prototype.source = function (path) { if (isUndefined(path)) return this.path(this._source) assert(isString(path), 'You must pass a source path string.') this._source = path return this } /** * Get or set the destination directory. * * @param {string} [path] * @return {string|Metalsmith} * * @example * metalsmith.destination('build'); // set destination * metalsmith.destination() // returns 'build' */ Metalsmith.prototype.destination = function (path) { if (!arguments.length) return this.path(this._destination) assert(isString(path), 'You must pass a destination path string.') this._destination = path return this } /** * Get or set the maximum number of files to open at once. * * @param {number} [max] * @returns {number|Metalsmith} * * @example * metalsmith.concurrency(20) // set concurrency to max 20 * metalsmith.concurrency() // returns 20 */ Metalsmith.prototype.concurrency = function (max) { if (isUndefined(max)) return this._concurrency assert(isNumber(max), 'You must pass a number for concurrency.') this._concurrency = max return this } /** * Get or set whether the destination directory will be removed before writing. * * @param {boolean} [clean] * @return {boolean|Metalsmith} * * @example * metalsmith.clean(true) // clean the destination directory * metalsmith.clean() // returns true */ Metalsmith.prototype.clean = function (clean) { if (isUndefined(clean)) return this._clean assert(isBoolean(clean), 'You must pass a boolean.') this._clean = clean return this } /** @typedef {Object} GrayMatterOptions */ /** * Optionally turn off frontmatter parsing or pass a [gray-matter options object](https://github.com/jonschlinkert/gray-matter/tree/4.0.2#option) * * @param {boolean|GrayMatterOptions} [frontmatter] * @return {boolean|Metalsmith} * * @example * metalsmith.frontmatter(false) // turn off front-matter parsing * metalsmith.frontmatter() // returns false * metalsmith.frontmatter({ excerpt: true }) */ Metalsmith.prototype.frontmatter = function (frontmatter) { if (isUndefined(frontmatter)) return this._frontmatter assert( isBoolean(frontmatter) || isObject(frontmatter), 'You must pass a boolean or a gray-matter options object: https://github.com/jonschlinkert/gray-matter/tree/4.0.2#options' ) this._frontmatter = frontmatter return this } /** * Get or set the list of filepaths or glob patterns to ignore * * @method Metalsmith#ignore * @param {string|string[]} [files] - The names or glob patterns of files or directories to ignore. * @return {Metalsmith|string[]} * * @example * metalsmith.ignore() // return a list of ignored file paths * metalsmith.ignore('layouts') // ignore the layouts directory * metalsmith.ignore(['.*', 'data.json']) // ignore dot files & a data file */ Metalsmith.prototype.ignore = function (files) { if (isUndefined(files)) return this.ignores.slice() this.ignores = this.ignores.concat(files) return this } /** * Resolve `paths` relative to the metalsmith `directory`. * * @param {...string} paths * @return {string} * * @example * metalsmith.path('./path','to/file.ext') */ Metalsmith.prototype.path = function (...paths) { return path.resolve.apply(path, [this.directory(), ...paths]) } /** * Match filepaths in the source directory by [glob](https://en.wikipedia.org/wiki/Glob_(programming)) pattern. * If `input` is not specified, patterns are matched against `Object.keys(files)` * * @param {string|string[]} patterns - one or more glob patterns * @param {string[]} [input] array of strings to match against * @param {import('micromatch').Options} options - [micromatch options](https://github.com/micromatch/micromatch#options), except `format` * @returns {string[]} An array of matching file paths */ Metalsmith.prototype.match = function (patterns, input, options) { input = input || Object.keys(this._files) if (!(input && input.length)) return [] return match(input, patterns, options) } /** * Get or set one or multiple metalsmith environment variables. Metalsmith env vars are case-insensitive. * @param {string|Object} [vars] name of the environment variable, or an object with `{ name: 'value' }` pairs * @param {string|number|boolean} [value] value of the environment variable * @returns {string|number|boolean|Object|Metalsmith} * @example * // pass all Node env variables * metalsmith.env(process.env) * // get all env variables * metalsmith.env() * // get DEBUG env variable * metalsmith.env('DEBUG') * // set DEBUG env variable (chainable) * metalsmith.env('DEBUG', '*') * // set multiple env variables at once (chainable) * // this does not clear previously set variables * metalsmith.env({ * DEBUG: false, * ENV: 'development' * }) */ Metalsmith.prototype.env = function (vars, value) { if (isString(vars)) { if (arguments.length === 1) { return this[symbol.env][vars.toUpperCase()] } if (!(isFunction(value) || isObject(value))) { this[symbol.env][vars.toUpperCase()] = value return this } throw new TypeError('Environment variable values can only be primitive: Number, Boolean, String or null') } if (isObject(vars)) { Object.entries(vars).forEach(([key, value]) => this.env(key, value)) return this } if (isUndefined(vars)) return Object.assign(Object.create(null), this[symbol.env]) } Metalsmith.prototype.debug = Debugger /** * Build with the current settings to the destination directory. * * @param {BuildCallback} [callback] * @return {Promise} * @fulfills {Files} * @rejects {Error} * * @example * metalsmith.build(function(error, files) { * if (error) throw error * console.log('Build success!') * }) */ Metalsmith.prototype.build = function (callback) { const clean = this.clean() const dest = this.destination() const result = (clean ? rm(dest) : Promise.resolve()) .then(() => { if (this.debug.enabled && this.env('DEBUG_LOG')) { this[symbol.log] = writeStream(this.path(this.env('DEBUG_LOG'))) this.debug.handle = fileLogHandler(this[symbol.log]) this.debug.colors = false return new Promise((resolve, reject) => { this[symbol.log].on('error', (err) => { let error = err if (error.code === 'ENOENT') { error = new Error( `Inexistant directory path "${path.dirname(this.env('DEBUG_LOG'))}" given for DEBUG_LOG` ) error.code = 'invalid_logpath' reject(error) } }) if (this[symbol.log].pending) { this[symbol.log].on('ready', () => resolve()) } else { resolve() } }) } }) .then(this.process.bind(this)) .then((files) => { return this.write(files).then(() => { if (this[symbol.log]) this[symbol.log].end() return files }) }) /* block required for Metalsmith 2.x callback-flow compat */ if (isFunction(callback)) { result.then((files) => callback(null, files), callback) } else { return result } } /** * Process files through plugins without writing out files. * * @method Metalsmith#process * @param {BuildCallback} [callback] * @return {Promise|void} * * @example * metalsmith.process((err, files) => { * if (err) throw err * console.log('Success') * console.log(this.metadata()) * }) */ Metalsmith.prototype.process = function (callback) { const result = this.read(this.source()).then((files) => { return this.run(files, this.plugins) }) /* block required for Metalsmith 2.x callback-flow compat */ if (callback) { result.then((files) => callback(null, files), callback) } else { return result } } /** * Run a set of `files` through the plugins stack. * * @method Metalsmith#run * @package * @param {Files} files * @param {Plugin[]} plugins * @return {Promise|void} */ Metalsmith.prototype.run = function (files, plugins, callback) { let debugValue = this.env('DEBUG') if (debugValue === false) { this.debug.disable() } else { if (debugValue === true) debugValue = '*' this.debug.enable(debugValue) } /* block required for Metalsmith 2.x callback-flow compat */ const last = arguments[arguments.length - 1] callback = isFunction(last) ? last : undefined plugins = Array.isArray(plugins) ? plugins : this.plugins this._files = files const ware = new Ware(plugins) const run = ware.run.bind(ware) const result = new Promise((resolve, reject) => { run(files, this, (err, files) => { if (err) reject(err) else resolve(files) }) }) /* block required for Metalsmith 2.x callback-flow compat */ if (callback) { result.then((files) => callback(null, files, this), callback) } else { return result } } /** * Read a dictionary of files from a `dir`, parsing frontmatter. If no directory * is provided, it will default to the source directory. * * @method Metalsmith#read * @package * @param {string} [dir] * @return {Promise|void} */ Metalsmith.prototype.read = function (dir, callback) { /* block required for Metalsmith 2.x callback-flow compat */ if (isFunction(dir) || !arguments.length) { callback = dir dir = this.source() } const read = this.readFile.bind(this) const concurrency = this.concurrency() const ignores = this.ignores || null const result = readdir(dir, ignores).then((paths) => { return batchAsync((p) => read(p), paths, concurrency).then((files) => { const result = paths.reduce((memo, file, i) => { file = path.relative(dir, file) memo[file] = files[i] return memo }, {}) return result }) }) /* block required for Metalsmith 2.x callback-flow compat */ if (callback) { result.then((files) => callback(null, files), callback) } else { return result } } /** * Read a `file` by path. If the path is not absolute, it will be resolved * relative to the source directory. * * @method Metalsmith#readFile * @package * @param {string} file * @returns {Promise|void} */ Metalsmith.prototype.readFile = function (file, callback) { const src = this.source() if (!path.isAbsolute(file)) file = path.resolve(src, file) const frontmatter = this.frontmatter() const result = Promise.all([ // @TODO: this stat should be passed from the readdir function, not done twice stat(file), readFile(file) ]) .then(([stats, buffer]) => { let ret = {} if (frontmatter && utf8(buffer)) { try { const parsed = matter(buffer.toString(), this._frontmatter) ret = parsed.data if (parsed.excerpt) { ret.excerpt = parsed.excerpt } ret.contents = Buffer.from(parsed.content) } catch (e) { const err = new Error('Invalid frontmatter in the file at: ' + file) err.code = 'invalid_frontmatter' return Promise.reject(err) } } else { ret.contents = buffer } ret.mode = Mode(stats).toOctal() ret.stats = stats return ret }) .catch((e) => { if (e.code == 'invalid_frontmatter') return Promise.reject(e) e.message = 'Failed to read the file at: ' + file + '\n\n' + e.message e.code = 'failed_read' return Promise.reject(e) }) if (isFunction(callback)) { result.then( (file) => callback(null, file), (err) => callback(err) ) } else { return result } } /** * Write a dictionary of `files` to a destination `dir`. If no directory is * provided, it will default to the destination directory. * * @method Metalsmith#write * @package * @param {Files} files * @param {string} [dir] * @returns {Promise|void} */ Metalsmith.prototype.write = function (files, dir, callback) { /* block required for Metalsmith 2.x callback-flow compat */ const last = arguments[arguments.length - 1] callback = isFunction(last) ? last : undefined dir = dir && !isFunction(dir) ? dir : this.destination() const write = this.writeFile.bind(this) const concurrency = this.concurrency() const keys = Object.keys(files) const operation = batchAsync( (key) => { return write(key, files[key]) }, keys, concurrency ) /* block required for Metalsmith 2.x callback-flow compat */ if (callback) { operation.then(() => callback(null), callback) } else { return operation } } /** * Write a `file` by path with `data`. If the path is not absolute, it will be * resolved relative to the destination directory. * * @method Metalsmith#writeFile * @package * @param {string} file * @param {File} data * @returns {Promise|void} */ Metalsmith.prototype.writeFile = function (file, data, callback) { const dest = this.destination() if (!path.isAbsolute(file)) file = path.resolve(dest, file) const result = outputFile(file, data.contents, data.mode).catch((e) => { e.code = 'failed_write' e.message = 'Failed to write the file at: ' + file + '\n\n' + e.message return Promise.reject(e) }) /* block required for Metalsmith 2.x callback-flow compat */ if (callback) { result.then(callback, callback) } else { return result } }