metalsmith
Version:
An extremely simple, pluggable static site generator.
962 lines (878 loc) • 28.8 kB
JavaScript
const assert = require('assert')
const Mode = require('stat-mode')
const path = require('path')
const { fileURLToPath } = require('url')
const watcher = require('./watcher')
const {
readdir,
batchAsync,
isFunction,
outputFile,
stat,
readFile,
writeStream,
rm,
isString,
isBoolean,
isObject,
isNumber,
isUndefined,
match
} = require('./helpers')
const Matter = require('./matter')
const utf8 = require('is-utf8')
const Ware = require('ware')
const { Debugger, fileLogHandler } = require('./debug')
const { Stats } = require('fs')
const { mkdir, copyFile } = require('fs/promises')
const symbol = {
env: Symbol('env'),
log: Symbol('log'),
watch: Symbol('watch'),
statik: Symbol('statik'),
closeWatcher: Symbol('closeWatcher')
}
/**
* Metalsmith representation of the files in `metalsmith.source()`.
* The keys represent the file paths and the values are {@link File} objects
* @typedef {Object.<string, File>} 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}
*/
/**
* @constructor
*/
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.matter = new Matter()
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
})
Object.defineProperty(this, symbol.watch, {
value: false,
enumerable: false,
writable: true
})
Object.defineProperty(this, symbol.closeWatcher, {
value: null,
enumerable: false,
writable: true
})
Object.defineProperty(this, symbol.statik, {
value: [],
enumerable: false,
writable: true
})
}
Object.defineProperty(Metalsmith, 'version', {
configurable: false,
writable: false,
value: '2.7.0'
})
/**
* 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.matter.options(frontmatter)
// @TODO: remove in 3.0, meant to be private & here only for backwards-compatibility
// careful because when false is passed _frontmatter === false whereas .matter.options has default 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)
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
/**
* Get or set files to consider static, i.e. that should be copied to `metalsmith.destination()` without being processed by plugins
* [API Docs](https://metalsmith.io/api/#Metalsmith+statik) | [Source code](https://github.com/metalsmith/metalsmith/blob/v2.6.0/lib/index.js#L316)
* @param {string|string[]} paths
* @returns {Metalsmith}
* @example
* metalsmith.statik(["assets","CNAME","api/static"]);
* const statik = metalsmith.statik()
* statik['library.css'].contents.toString() // 'library.css'
*/
Metalsmith.prototype.statik = function (paths) {
if (!arguments.length) return this[symbol.statik]
if (isString(paths)) paths = [paths]
assert(Array.isArray(paths), `paths must be an array, got ${paths} instead.`)
this[symbol.statik] = paths
return this
}
/**
* Build with the current settings to the destination directory.
*
* @param {BuildCallback} [callback]
* @return {Promise<Files>}
* @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 = new Promise((resolve, reject) => {
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
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)
} else {
reject(error)
}
})
if (this[symbol.log].pending) {
this[symbol.log].on('ready', () => resolve())
} else {
resolve()
}
} else {
resolve()
}
})
.catch((err) => {
if (isFunction(callback)) callback(err)
return Promise.reject(err)
})
.then(() => {
return new Promise((resolve, reject) => {
this.process((err, files) => {
if (err) {
if (isFunction(callback)) {
callback(err)
return
}
reject(err)
return
}
return (clean ? rm(dest) : Promise.resolve())
.catch((err) => {
if (isFunction(callback)) {
callback(err)
}
return Promise.reject(err)
})
.then(() =>
Promise.all(
Object.keys(this[symbol.statik]).map((staticPath) => {
const src = this.path(this.source(), this[symbol.statik][staticPath].contents.toString())
const target = path.join(dest, staticPath)
return mkdir(path.dirname(target), { recursive: true }).then(() => copyFile(src, target))
})
)
)
.then(() =>
this.write(files)
.catch((err) => {
if (isFunction(callback)) callback(err)
reject(err)
})
.then(() => {
if (this[symbol.log]) this[symbol.log].end()
if (isFunction(callback)) callback(null, files)
resolve(files)
})
)
})
})
})
/* block required for Metalsmith 2.x callback-flow compat */
if (!isFunction(callback)) {
return result
}
}
/**
* **EXPERIMENTAL — Caution**
* * not to be used with @metalsmith/metadata <= 0.2.0: a bug may trigger an infinite loop
* * not to be used with existing watch plugins
* * metalsmith.process/build are **not awaitable** when watching is enabled.
* Instead of running once at the build's end, callbacks passed to these methods will run on every rebuild.
*
* Set the list of paths to watch and trigger rebuilds on. The watch method will skip files ignored with `metalsmith.ignore()`
* and will do partial (true) or full (false) rebuilds depending on the `metalsmith.clean()` setting.
* It can be used both for rebuilding in-memory with `metalsmith.process` or writing to file system with `metalsmith.build`,
* @method Metalsmith#watch
* @param {boolean|string|string[]} [paths]
* @return {Metalsmith|Promise<void>|boolean|import('chokidar').ChokidarOptions}
* @example
*
* metalsmith
* .ignore(['wont-be-watched']) // ignored
* .clean(false) // do partial rebuilds
* .watch(true) // watch all files in metalsmith.source()
* .watch(['lib','src']) // or watch files in directories 'lib' and 'src'
*
* if (process.argv[2] === '--dry-run') {
* metalsmith.process(onRebuild) // reprocess in memory without writing to disk
* } else {
* metalsmith.build(onRebuild) // rewrite to disk
* }
*
* function onRebuild(err, files) {
* if (err) {
* metalsmith.watch(false) // stop watching
* .finally(() => console.log(err)) // and log build error
* }
* console.log('reprocessed files', Object.keys(files).join(', ')))
* }
*/
Metalsmith.prototype.watch = function (options) {
if (isUndefined(options)) return this[symbol.watch]
if (!options) {
// if watch has previously been enabled and is now passed false, close the watcher
this[symbol.watch] = false
if (options === false && typeof this[symbol.closeWatcher] === 'function') {
return this[symbol.closeWatcher]()
}
} else {
if (isString(options) || Array.isArray(options)) options = { paths: options }
else if (options === true) options = { paths: [this.source()] }
this[symbol.watch] = {
paths: [this.source()],
awaitWriteFinish: false,
...options,
alwaysStat: false,
cwd: this.directory(),
ignored: this.ignore(),
ignoreInitial: true
}
}
return this
}
/**
* Process files through plugins without writing out files.
*
* @method Metalsmith#process
* @param {BuildCallback} [callback]
* @return {Promise<Files>|void}
*
* @example
* metalsmith.process((err, files) => {
* if (err) throw err
* console.log('Success')
* console.log(this.metadata())
* })
*/
Metalsmith.prototype.process = function (callback) {
let result = this.read(this.source())
if (this.watch()) {
let msWatcher
return result.then((files) => {
msWatcher = watcher(files, this)
msWatcher(this[symbol.watch], callback).then(
() => {
this[symbol.closeWatcher] = msWatcher.close
},
(err) => {
this[symbol.closeWatcher] = msWatcher.close
return callback(err)
}
)
})
} else {
result = result.then((files) => 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<Files>|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<Files>|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, root: dir }).then((files) => {
const relpaths = files.map(([p]) => p)
/** List of static paths with double globstar variants of directory paths added,
* so that specifying a dir is the same as specifying 'all files within this dir'
* @example
* ['dir/subdir'] => ['dir/subdir','dir/subdir/**']
**/
// if this[symbol.statik] has already resolved previously it stores an object of Metalsmith.Files
// this happens when metalsmith.read() is called again, such as when the same build is done multiple times without reconfiguration
// but in that case, the first resolution is correct and can be reused
const expandedPatterns = (Array.isArray(this[symbol.statik]) ? this[symbol.statik] : []).reduce((all, pattern) => {
all.push(pattern)
const likelyADirectory = !path.extname(pattern) && !relpaths.includes(pattern)
if (likelyADirectory) all.push(`${pattern}/**`)
return all
}, [])
const staticPaths = this.match(expandedPatterns, relpaths)
const statik = {},
nonStatik = []
for (const [p, stats] of files) {
if (staticPaths.includes(p)) {
statik[p] = Object.create(
{},
{
mode: {
value: Mode(stats).toOctal(),
configurable: false,
enumerable: true
},
stats: {
value: Object.freeze(stats),
configurable: false,
enumerable: true
},
contents: {
value: Buffer.from(p),
configurable: false,
enumerable: true
}
}
)
} else {
nonStatik.push([p, stats])
}
}
this[symbol.statik] = statik
return batchAsync(
([p, stats]) => {
const s = new String(path.join(dir, p))
s.stats = stats
return read(s)
},
nonStatik,
concurrency
).then((files) => {
const result = nonStatik
.map(([p]) => p)
.reduce((memo, file, i) => {
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<File>|void}
*/
Metalsmith.prototype.readFile = function (file, callback) {
// this is a clunky way to allow ms.read to call this fn with new String() and attach a stats property,
// to avoid stat'ing the file twice or introducing a breaking change to the API.
// This usage (with preloaded stats) is unlikely to be used by consumers of metalsmith
const stats = file instanceof String && file.stats
file = file.toString()
const src = this.source()
if (!path.isAbsolute(file)) file = path.resolve(src, file)
const frontmatter = this.frontmatter()
const result = Promise.all([stats instanceof Stats ? Promise.resolve(stats) : stat(file), readFile(file)])
.then(([stats, buffer]) => {
let ret = {}
if (frontmatter && utf8(buffer)) {
try {
ret = this.matter.parse(buffer)
} catch {
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]
* @param {BuildCallback} [callback]
* @returns {Promise<void>|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>|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
}
}
/**
* Like Javascript's dynamic `import()`, with CJS/ESM support for loading default exports, all or a single named export, and JSON files.
* Relative paths are resolved against `metalsmith.directory()`
* @param {string} specifier
* @param {string} [namedExport]
* @returns {Promise<*>}
* @example
* await metalsmith.imports('metalsmith') // Metalsmith
* await metalsmith.imports('./data.json') // object
* await metalsmith.imports('./helpers/index.js', 'formatDate') // function
*/
Metalsmith.prototype.imports = function (specifier, namedExport) {
// if specifier is falsy, throw
if (!specifier) {
const err = new Error(`Metalsmith#imports() - "${specifier}" is not a valid import specifier.`)
return Promise.reject(err)
}
// if specifier is truthy but not string, consider it resolved
if (typeof specifier !== 'string') return Promise.resolve(specifier)
const ext = path.extname(specifier)
let load = (mod) => import(mod),
resolved
if (ext === '.json') load = (mod) => readFile(fileURLToPath(mod), 'utf-8')
// Don't normalize node_modules specifiers like 'metalsmith' in the following 2 operations
if (specifier.startsWith('.')) {
resolved = true
specifier = path.resolve(this.directory(), specifier)
// commonjs extensionless import compat, doesn't work for index.js dir imports though
if (!ext) specifier = `${specifier}.js`
}
if (path.isAbsolute(specifier)) {
specifier = `file://${specifier}`
}
return load(specifier)
.catch((original) => {
const err = new Error(
`Metalsmith#imports() could not load module at ${resolved ? 'resolved' : 'import'} path "${specifier}".`
)
err.cause = original
throw err
})
.then((s) => {
if (ext === '.json') {
try {
return JSON.parse(s)
} catch (original) {
const err = new Error(`Metalsmith#imports could not load "${specifier}" due to invalid JSON.`)
err.cause = original
throw err
}
}
if (arguments.length === 1) {
const namedExports = Object.keys(s).filter((d) => d !== 'default' && d !== 'module.exports')
// return first named export
if (namedExports.length === 1) return s[namedExports[0]]
// fall back to default
if (Reflect.has(s, 'default')) return s.default
// if there are multiple named exports but no default, act as if instructed import * from specifier
return s
}
// if export name exists (either explicit, or implicit 'default') return it, else throw
if (Reflect.has(s, namedExport)) {
return s[namedExport]
}
throw new Error(`Metalsmith#imports() could not load "${namedExport}" from module "${specifier}`)
})
}