metalsmith
Version:
An extremely simple, pluggable static site generator.
165 lines (144 loc) • 5.12 kB
JavaScript
/* c8 ignore start */
const chokidar = require('chokidar')
// to be replaced in distant future by native structuredClone when dropping Node <17 support
const cloneDeepWith = require('lodash.clonedeepwith')
const crypto = require('crypto')
const { relative } = require('path')
const { rm } = require('./helpers')
function sourceRelPath(p, ms) {
return relative(ms.source(), ms.path(p))
}
function isInSource(p) {
return !p.startsWith('..')
}
function computeHashMap(files) {
return Object.entries(files).reduce((hashes, [path, file]) => {
hashes[path] = crypto.createHash('md5').update(file.contents).digest('hex')
return hashes
}, {})
}
/**
* @type {Object<string, string>} HashMap
*/
/**
* Return the keys of `map1` that are different from `map2`
* @param {HashMap} map1
* @param {HashMap} map2
* @returns {Array}
*/
function diffHashMap(map1, map2) {
return Object.keys(map1).filter((path) => map1[path] !== map2[path])
}
module.exports = function watchable(files, metalsmith) {
const clean = metalsmith.clean()
const meta = metalsmith.metadata()
const fileCache = files
let lastHashmap
function rerun() {
const originalFiles = cloneDeepWith(fileCache, (value) => {
if (Buffer.isBuffer(value)) return Buffer.from(value)
})
return metalsmith.metadata(meta).run(originalFiles, metalsmith.plugins)
}
function transformFilesObj(evt, p, metalsmith) {
// we only care about in-source files & dirs to update the fileCache
// other files are eventually added or processed by plugins
let filesTransform = Promise.resolve()
const relPath = sourceRelPath(p, metalsmith)
if (isInSource(relPath)) {
switch (evt) {
case 'unlinkDir':
metalsmith.match(relPath, Object.keys(fileCache)).forEach((r) => delete fileCache[r])
break
case 'unlink':
delete fileCache[relPath]
break
case 'add':
case 'change':
filesTransform = metalsmith.readFile(metalsmith.path(p)).then((file) => {
fileCache[relPath] = file
})
break
}
}
return filesTransform
}
function watcherFn({ paths, ...options }, onRebuild) {
const watcher = chokidar.watch(paths || metalsmith.source(), options)
// assume chokidar's ready event fires reliably
// the promise needs to be created immediately after watcher initialization to avoid missing the ready event
const watcherReady = new Promise((resolve) => watcher.on('ready', () => resolve()))
watcherReady.then(() => {
watcherFn.close = function () {
return watcher.unwatch(paths).close()
}
})
const eventqueue = []
// eslint-disable-next-line no-unused-vars
let inTheMiddleOfABuild = false
let run
watcher.on('all', (evt, p) => {
// the metalsmith Files object does not output empty dirs,
// wait for the file add/change events instead
if (evt === 'addDir') return
eventqueue.push([evt, p])
clearTimeout(run)
run = setTimeout(() => {
inTheMiddleOfABuild = true
const fileTransforms = Promise.all(eventqueue.map(([evt, p]) => transformFilesObj(evt, p, metalsmith)))
fileTransforms.then(() => {
eventqueue.splice(0, eventqueue.length)
const latestRun = rerun()
if (clean) {
latestRun
.then(
(files) => onRebuild(null, files),
(err) => onRebuild(err)
)
.finally(() => {
inTheMiddleOfABuild = false
})
return
}
latestRun.then(
(files) => {
const newHashMap = computeHashMap(files)
const changedOrRemoved = diffHashMap(lastHashmap, newHashMap)
const addedFiles = diffHashMap(newHashMap, lastHashmap).filter((p) => !changedOrRemoved.includes(p))
const removedFiles = changedOrRemoved.filter((f) => !files[f])
const changedFiles = changedOrRemoved.filter((f) => !!files[f])
const output = [...addedFiles, ...changedFiles].reduce((all, current) => {
all[current] = files[current]
return all
}, {})
lastHashmap = newHashMap
Promise.all(removedFiles.map((f) => rm(f)))
.then(() => onRebuild(null, output), onRebuild)
.finally(() => {
inTheMiddleOfABuild = false
})
},
(err) => onRebuild(err)
)
})
}, 0)
})
return new Promise((resolve, reject) => {
rerun()
.then((files) => {
if (!clean) lastHashmap = computeHashMap(files)
watcherReady.then(() => {
onRebuild(null, files)
resolve(function closeWatcher() {
return watcher.unwatch(paths).close()
})
}, reject)
})
.catch((err) => {
watcherReady.then(() => reject(err), reject)
})
})
}
return watcherFn
}
/* c8 ignore end */