You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
267 lines
6.7 KiB
267 lines
6.7 KiB
'use strict'; |
|
|
|
const { join, sep } = require('path'); |
|
const Promise = require('bluebird'); |
|
const File = require('./file'); |
|
const { Pattern, createSha1Hash } = require('hexo-util'); |
|
const { createReadStream, readdir, stat, watch } = require('hexo-fs'); |
|
const { magenta } = require('picocolors'); |
|
const { EventEmitter } = require('events'); |
|
const { isMatch, makeRe } = require('micromatch'); |
|
|
|
const defaultPattern = new Pattern(() => ({})); |
|
|
|
class Box extends EventEmitter { |
|
constructor(ctx, base, options) { |
|
super(); |
|
|
|
this.options = Object.assign({ |
|
persistent: true, |
|
awaitWriteFinish: { |
|
stabilityThreshold: 200 |
|
} |
|
}, options); |
|
|
|
if (!base.endsWith(sep)) { |
|
base += sep; |
|
} |
|
|
|
this.context = ctx; |
|
this.base = base; |
|
this.processors = []; |
|
this._processingFiles = {}; |
|
this.watcher = null; |
|
this.Cache = ctx.model('Cache'); |
|
this.File = this._createFileClass(); |
|
let targets = this.options.ignored || []; |
|
if (ctx.config.ignore && ctx.config.ignore.length) { |
|
targets = targets.concat(ctx.config.ignore); |
|
} |
|
this.ignore = targets; |
|
this.options.ignored = targets.map(s => toRegExp(ctx, s)).filter(x => x); |
|
} |
|
_createFileClass() { |
|
const ctx = this.context; |
|
|
|
class _File extends File { |
|
render(options) { |
|
return ctx.render.render({ |
|
path: this.source |
|
}, options); |
|
} |
|
|
|
renderSync(options) { |
|
return ctx.render.renderSync({ |
|
path: this.source |
|
}, options); |
|
} |
|
} |
|
|
|
_File.prototype.box = this; |
|
|
|
return _File; |
|
} |
|
|
|
addProcessor(pattern, fn) { |
|
if (!fn && typeof pattern === 'function') { |
|
fn = pattern; |
|
pattern = defaultPattern; |
|
} |
|
|
|
if (typeof fn !== 'function') throw new TypeError('fn must be a function'); |
|
if (!(pattern instanceof Pattern)) pattern = new Pattern(pattern); |
|
|
|
this.processors.push({ |
|
pattern, |
|
process: fn |
|
}); |
|
} |
|
|
|
_readDir(base, prefix = '') { |
|
const results = []; |
|
return readDirWalker(base, results, this.ignore, prefix) |
|
.return(results) |
|
.map(path => this._checkFileStatus(path)) |
|
.map(file => this._processFile(file.type, file.path).return(file.path)); |
|
} |
|
|
|
_checkFileStatus(path) { |
|
const { Cache, context: ctx } = this; |
|
const src = join(this.base, path); |
|
|
|
return Cache.compareFile( |
|
escapeBackslash(src.substring(ctx.base_dir.length)), |
|
() => getHash(src), |
|
() => stat(src) |
|
).then(result => ({ |
|
type: result.type, |
|
path |
|
})); |
|
} |
|
|
|
process(callback) { |
|
const { base, Cache, context: ctx } = this; |
|
|
|
return stat(base).then(stats => { |
|
if (!stats.isDirectory()) return; |
|
|
|
// Check existing files in cache |
|
const relativeBase = escapeBackslash(base.substring(ctx.base_dir.length)); |
|
const cacheFiles = Cache.filter(item => item._id.startsWith(relativeBase)).map(item => item._id.substring(relativeBase.length)); |
|
|
|
// Handle deleted files |
|
return this._readDir(base) |
|
.then(files => cacheFiles.filter(path => !files.includes(path))) |
|
.map(path => this._processFile(File.TYPE_DELETE, path)); |
|
}).catch(err => { |
|
if (err && err.code !== 'ENOENT') throw err; |
|
}).asCallback(callback); |
|
} |
|
|
|
_processFile(type, path) { |
|
if (this._processingFiles[path]) { |
|
return Promise.resolve(); |
|
} |
|
|
|
this._processingFiles[path] = true; |
|
|
|
const { base, File, context: ctx } = this; |
|
|
|
this.emit('processBefore', { |
|
type, |
|
path |
|
}); |
|
|
|
return Promise.reduce(this.processors, (count, processor) => { |
|
const params = processor.pattern.match(path); |
|
if (!params) return count; |
|
|
|
const file = new File({ |
|
source: join(base, path), |
|
path, |
|
params, |
|
type |
|
}); |
|
|
|
return Reflect.apply(Promise.method(processor.process), ctx, [file]) |
|
.thenReturn(count + 1); |
|
}, 0).then(count => { |
|
if (count) { |
|
ctx.log.debug('Processed: %s', magenta(path)); |
|
} |
|
|
|
this.emit('processAfter', { |
|
type, |
|
path |
|
}); |
|
}).catch(err => { |
|
ctx.log.error({err}, 'Process failed: %s', magenta(path)); |
|
}).finally(() => { |
|
this._processingFiles[path] = false; |
|
}).thenReturn(path); |
|
} |
|
|
|
watch(callback) { |
|
if (this.isWatching()) { |
|
return Promise.reject(new Error('Watcher has already started.')).asCallback(callback); |
|
} |
|
|
|
const { base } = this; |
|
|
|
function getPath(path) { |
|
return escapeBackslash(path.substring(base.length)); |
|
} |
|
|
|
return this.process().then(() => watch(base, this.options)).then(watcher => { |
|
this.watcher = watcher; |
|
|
|
watcher.on('add', path => { |
|
this._processFile(File.TYPE_CREATE, getPath(path)); |
|
}); |
|
|
|
watcher.on('change', path => { |
|
this._processFile(File.TYPE_UPDATE, getPath(path)); |
|
}); |
|
|
|
watcher.on('unlink', path => { |
|
this._processFile(File.TYPE_DELETE, getPath(path)); |
|
}); |
|
|
|
watcher.on('addDir', path => { |
|
let prefix = getPath(path); |
|
if (prefix) prefix += '/'; |
|
|
|
this._readDir(path, prefix); |
|
}); |
|
}).asCallback(callback); |
|
} |
|
|
|
unwatch() { |
|
if (!this.isWatching()) return; |
|
|
|
this.watcher.close(); |
|
this.watcher = null; |
|
} |
|
|
|
isWatching() { |
|
return Boolean(this.watcher); |
|
} |
|
} |
|
|
|
function escapeBackslash(path) { |
|
// Replace backslashes on Windows |
|
return path.replace(/\\/g, '/'); |
|
} |
|
|
|
function getHash(path) { |
|
const src = createReadStream(path); |
|
const hasher = createSha1Hash(); |
|
|
|
const finishedPromise = new Promise((resolve, reject) => { |
|
src.once('error', reject); |
|
src.once('end', resolve); |
|
}); |
|
|
|
src.on('data', chunk => { hasher.update(chunk); }); |
|
|
|
return finishedPromise.then(() => hasher.digest('hex')); |
|
} |
|
|
|
function toRegExp(ctx, arg) { |
|
if (!arg) return null; |
|
if (typeof arg !== 'string') { |
|
ctx.log.warn('A value of "ignore:" section in "_config.yml" is not invalid (not a string)'); |
|
return null; |
|
} |
|
const result = makeRe(arg); |
|
if (!result) { |
|
ctx.log.warn('A value of "ignore:" section in "_config.yml" can not be converted to RegExp:' + arg); |
|
return null; |
|
} |
|
return result; |
|
} |
|
|
|
function isIgnoreMatch(path, ignore) { |
|
return path && ignore && ignore.length && isMatch(path, ignore); |
|
} |
|
|
|
function readDirWalker(base, results, ignore, prefix) { |
|
if (isIgnoreMatch(base, ignore)) return Promise.resolve(); |
|
|
|
return Promise.map(readdir(base).catch(err => { |
|
if (err && err.code === 'ENOENT') return []; |
|
throw err; |
|
}), async path => { |
|
const fullpath = join(base, path); |
|
const stats = await stat(fullpath); |
|
const prefixdPath = `${prefix}${path}`; |
|
if (stats.isDirectory()) { |
|
return readDirWalker(fullpath, results, ignore, `${prefixdPath}/`); |
|
} |
|
if (!isIgnoreMatch(fullpath, ignore)) { |
|
results.push(prefixdPath); |
|
} |
|
}); |
|
} |
|
|
|
module.exports = Box;
|
|
|