Skip to content

Commit 9d40674

Browse files
committed
Resolves #379: add metalsmith.watch option setter and watcher
1 parent 48a0167 commit 9d40674

File tree

4 files changed

+272
-58
lines changed

4 files changed

+272
-58
lines changed

lib/index.js

Lines changed: 103 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
const assert = require('assert')
44
const Mode = require('stat-mode')
55
const path = require('path')
6+
const watcher = require('./watcher')
7+
68
const {
79
readdir,
810
batchAsync,
@@ -26,7 +28,9 @@ const { Debugger, fileLogHandler } = require('./debug')
2628

2729
const symbol = {
2830
env: Symbol('env'),
29-
log: Symbol('log')
31+
log: Symbol('log'),
32+
watch: Symbol('watch'),
33+
closeWatcher: Symbol('closeWatcher')
3034
}
3135

3236
/**
@@ -109,6 +113,9 @@ module.exports = Metalsmith
109113
* @return {Metalsmith}
110114
*/
111115

116+
/**
117+
* @constructor
118+
*/
112119
function Metalsmith(directory) {
113120
if (!(this instanceof Metalsmith)) return new Metalsmith(directory)
114121
assert(directory, 'You must pass a working directory path.')
@@ -131,6 +138,16 @@ function Metalsmith(directory) {
131138
enumerable: false,
132139
writable: true
133140
})
141+
Object.defineProperty(this, symbol.watch, {
142+
value: false,
143+
enumerable: false,
144+
writable: true
145+
})
146+
Object.defineProperty(this, symbol.closeWatcher, {
147+
value: null,
148+
enumerable: false,
149+
writable: true
150+
})
134151
}
135152

136153
/**
@@ -412,20 +429,22 @@ Metalsmith.prototype.build = function (callback) {
412429
if (this[symbol.log].pending) {
413430
this[symbol.log].on('ready', () => resolve())
414431
} else {
415-
/* istanbul ignore next */
416432
resolve()
417433
}
418434
})
419435
}
420436
})
421-
.then(this.process.bind(this))
422-
.then((files) => {
423-
return this.write(files).then(() => {
424-
if (this[symbol.log]) this[symbol.log].end()
425-
return files
437+
.then(
438+
this.process.bind(this, (err, files) => {
439+
if (err) throw err
440+
return this.write(files)
441+
.then(() => {
442+
if (this[symbol.log]) this[symbol.log].end()
443+
if (isFunction(callback)) callback(null, files)
444+
})
445+
.catch(callback)
426446
})
427-
})
428-
447+
)
429448
/* block required for Metalsmith 2.x callback-flow compat */
430449
if (isFunction(callback)) {
431450
result.then((files) => callback(null, files), callback)
@@ -434,6 +453,65 @@ Metalsmith.prototype.build = function (callback) {
434453
}
435454
}
436455

456+
/**
457+
* **EXPERIMENTAL — Caution**
458+
* * not to be used with @metalsmith/metadata <= 0.2.0: a bug may trigger an infinite loop
459+
* * not to be used with existing watch plugins
460+
* * metalsmith.process/build are **not awaitable** when watching is enabled.
461+
* Instead of running once at the build's end, callbacks passed to these methods will run on every rebuild.
462+
*
463+
* Set the list of paths to watch and trigger rebuilds on. The watch method will skip files ignored with `metalsmith.ignore()`
464+
* and will do partial (true) or full (false) rebuilds depending on the `metalsmith.clean()` setting.
465+
* It can be used both for rebuilding in-memory with `metalsmith.process` or writing to file system with `metalsmith.build`,
466+
* @method Metalsmith#watch
467+
* @param {boolean|string|string[]} [paths]
468+
* @return {Metalsmith|Promise<void>|boolean|import('chokidar').WatchOptions}
469+
* @example
470+
*
471+
* metalsmith
472+
* .ignore(['wont-be-watched']) // ignored
473+
* .clean(false) // do partial rebuilds
474+
* .watch(true) // watch all files in metalsmith.source()
475+
* .watch(['lib','src']) // or watch files in directories 'lib' and 'src'
476+
*
477+
* if (process.argv[2] === '--dry-run') {
478+
* metalsmith.process(onRebuild) // reprocess in memory without writing to disk
479+
* } else {
480+
* metalsmith.build(onRebuild) // rewrite to disk
481+
* }
482+
*
483+
* function onRebuild(err, files) {
484+
* if (err) {
485+
* metalsmith.watch(false) // stop watching
486+
* .finally(() => console.log(err)) // and log build error
487+
* }
488+
* console.log('reprocessed files', Object.keys(files).join(', ')))
489+
* }
490+
*/
491+
Metalsmith.prototype.watch = function (options) {
492+
if (isUndefined(options)) return this[symbol.watch]
493+
if (!options) {
494+
// if watch has previously been enabled and is now passed false, close the watcher
495+
this[symbol.watch] = false
496+
if (options === false && typeof this[symbol.closeWatcher] === 'function') {
497+
return this[symbol.closeWatcher]()
498+
}
499+
} else {
500+
if (isString(options) || Array.isArray(options)) options = { paths: options }
501+
else if (options === true) options = { paths: this.source() }
502+
503+
this[symbol.watch] = {
504+
paths: options.paths,
505+
alwaysStat: false,
506+
cwd: this.directory(),
507+
ignored: this.ignore(),
508+
ignoreInitial: true,
509+
awaitWriteFinish: true
510+
}
511+
}
512+
return this
513+
}
514+
437515
/**
438516
* Process files through plugins without writing out files.
439517
*
@@ -450,15 +528,24 @@ Metalsmith.prototype.build = function (callback) {
450528
*/
451529

452530
Metalsmith.prototype.process = function (callback) {
453-
const result = this.read(this.source()).then((files) => {
454-
return this.run(files, this.plugins)
455-
})
531+
const result = this.read(this.source())
456532

457-
/* block required for Metalsmith 2.x callback-flow compat */
458-
if (callback) {
459-
result.then((files) => callback(null, files), callback)
533+
if (this.watch()) {
534+
return result.then((files) => {
535+
const msWatcher = watcher(files, this)
536+
msWatcher(this[symbol.watch], callback).then((close) => {
537+
this[symbol.closeWatcher] = close
538+
})
539+
})
460540
} else {
461-
return result
541+
result.then((files) => this.run(files, this.plugins))
542+
543+
/* block required for Metalsmith 2.x callback-flow compat */
544+
if (callback) {
545+
result.then((files) => callback(null, files), callback)
546+
} else {
547+
return result
548+
}
462549
}
463550
}
464551

lib/watcher.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
const chokidar = require('chokidar')
2+
// to be replaced in distant future by native structuredClone when dropping Node <17 support
3+
const cloneDeep = require('lodash.clonedeep')
4+
const crypto = require('crypto')
5+
const { relative } = require('path')
6+
const { rm } = require('./helpers')
7+
8+
function sourceRelPath(p, ms) {
9+
return relative(ms.source(), ms.path(p))
10+
}
11+
function isInSource(p) {
12+
return !p.startsWith('..')
13+
}
14+
15+
function computeHashMap(files) {
16+
return Object.entries(files).reduce((hashes, [path, file]) => {
17+
hashes[path] = crypto.createHash('md5').update(file.contents).digest('hex')
18+
return hashes
19+
}, {})
20+
}
21+
22+
/**
23+
* @type {Object<string, string>} HashMap
24+
*/
25+
26+
/**
27+
* Return the keys of `map1` that are different from `map2`
28+
* @param {HashMap} map1
29+
* @param {HashMap} map2
30+
* @returns {Array}
31+
*/
32+
function diffHashMap(map1, map2) {
33+
return Object.keys(map1).filter((path) => map1[path] !== map2[path])
34+
}
35+
36+
module.exports = function watchable(files, metalsmith) {
37+
const clean = metalsmith.clean()
38+
const meta = metalsmith.metadata()
39+
const fileCache = files
40+
let lastHashmap
41+
42+
function rerun() {
43+
return metalsmith.metadata(meta).run(cloneDeep(fileCache), metalsmith.plugins)
44+
}
45+
46+
function transformFilesObj(evt, p, metalsmith) {
47+
// we only care about in-source files & dirs to update the fileCache
48+
// other files are eventually added or processed by plugins
49+
let filesTransform = Promise.resolve()
50+
const relPath = sourceRelPath(p, metalsmith)
51+
52+
if (isInSource(relPath)) {
53+
switch (evt) {
54+
case 'unlinkDir':
55+
metalsmith.match(relPath, Object.keys(fileCache)).forEach((r) => delete fileCache[r])
56+
break
57+
case 'unlink':
58+
delete fileCache[relPath]
59+
break
60+
case 'add':
61+
case 'change':
62+
filesTransform = metalsmith.readFile(metalsmith.path(p)).then((file) => {
63+
fileCache[relPath] = file
64+
})
65+
break
66+
}
67+
}
68+
69+
return filesTransform
70+
}
71+
72+
return function watcher({ paths, ...options }, onRebuild) {
73+
const watcher = chokidar.watch(paths || metalsmith.source(), options)
74+
75+
const eventqueue = []
76+
// eslint-disable-next-line no-unused-vars
77+
let inTheMiddleOfABuild = false
78+
let run
79+
80+
watcher.on('all', (evt, p) => {
81+
// eslint-disable-next-line no-console
82+
console.log(evt, p)
83+
84+
// the metalsmith Files object does not output empty dirs,
85+
// wait for the file add/change events instead
86+
if (evt === 'addDir') return
87+
88+
eventqueue.push([evt, p])
89+
90+
clearTimeout(run)
91+
run = setTimeout(() => {
92+
inTheMiddleOfABuild = true
93+
const fileTransforms = Promise.all(eventqueue.map(([evt, p]) => transformFilesObj(evt, p, metalsmith)))
94+
95+
fileTransforms.then(() => {
96+
eventqueue.splice(0, eventqueue.length)
97+
const latestRun = rerun()
98+
99+
if (clean) {
100+
latestRun
101+
.then(
102+
(files) => onRebuild(null, files),
103+
(err) => onRebuild(err)
104+
)
105+
.finally(() => {
106+
inTheMiddleOfABuild = false
107+
})
108+
return
109+
}
110+
111+
latestRun.then(
112+
(files) => {
113+
const newHashMap = computeHashMap(files)
114+
const changedOrRemoved = diffHashMap(lastHashmap, newHashMap)
115+
const addedFiles = diffHashMap(newHashMap, lastHashmap).filter((p) => !changedOrRemoved.includes(p))
116+
const removedFiles = changedOrRemoved.filter((f) => !files[f])
117+
const changedFiles = changedOrRemoved.filter((f) => !!files[f])
118+
const output = [...addedFiles, ...changedFiles].reduce((all, current) => {
119+
all[current] = files[current]
120+
return all
121+
}, {})
122+
lastHashmap = newHashMap
123+
// eslint-disable-next-line no-console
124+
console.log({ addedFiles, removedFiles, changedFiles })
125+
126+
Promise.all(removedFiles.map((f) => rm(f)))
127+
.then(() => onRebuild(null, output), onRebuild)
128+
.finally(() => {
129+
inTheMiddleOfABuild = false
130+
})
131+
},
132+
(err) => onRebuild(err)
133+
)
134+
})
135+
}, 1000)
136+
})
137+
return new Promise((resolve, reject) => {
138+
rerun()
139+
.then((files) => {
140+
if (!clean) lastHashmap = computeHashMap(files)
141+
watcher.on('ready', () => {
142+
onRebuild(null, files)
143+
resolve(function closeWatcher() {
144+
return watcher.unwatch(paths).close()
145+
})
146+
})
147+
})
148+
.catch(reject)
149+
})
150+
}
151+
}

0 commit comments

Comments
 (0)