index.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. 'use strict';
  2. const { join, sep } = require('path');
  3. const Promise = require('bluebird');
  4. const File = require('./file');
  5. const { Pattern, createSha1Hash } = require('hexo-util');
  6. const { createReadStream, readdir, stat, watch } = require('hexo-fs');
  7. const { magenta } = require('chalk');
  8. const { EventEmitter } = require('events');
  9. const { isMatch, makeRe } = require('micromatch');
  10. const defaultPattern = new Pattern(() => ({}));
  11. class Box extends EventEmitter {
  12. constructor(ctx, base, options) {
  13. super();
  14. this.options = Object.assign({
  15. persistent: true,
  16. awaitWriteFinish: {
  17. stabilityThreshold: 200
  18. }
  19. }, options);
  20. if (!base.endsWith(sep)) {
  21. base += sep;
  22. }
  23. this.context = ctx;
  24. this.base = base;
  25. this.processors = [];
  26. this._processingFiles = {};
  27. this.watcher = null;
  28. this.Cache = ctx.model('Cache');
  29. this.File = this._createFileClass();
  30. let targets = this.options.ignored || [];
  31. if (ctx.config.ignore && ctx.config.ignore.length) {
  32. targets = targets.concat(ctx.config.ignore);
  33. }
  34. this.ignore = targets;
  35. this.options.ignored = targets.map(s => toRegExp(ctx, s)).filter(x => x);
  36. }
  37. _createFileClass() {
  38. const ctx = this.context;
  39. class _File extends File {
  40. render(options) {
  41. return ctx.render.render({
  42. path: this.source
  43. }, options);
  44. }
  45. renderSync(options) {
  46. return ctx.render.renderSync({
  47. path: this.source
  48. }, options);
  49. }
  50. }
  51. _File.prototype.box = this;
  52. return _File;
  53. }
  54. addProcessor(pattern, fn) {
  55. if (!fn && typeof pattern === 'function') {
  56. fn = pattern;
  57. pattern = defaultPattern;
  58. }
  59. if (typeof fn !== 'function') throw new TypeError('fn must be a function');
  60. if (!(pattern instanceof Pattern)) pattern = new Pattern(pattern);
  61. this.processors.push({
  62. pattern,
  63. process: fn
  64. });
  65. }
  66. _readDir(base, prefix = '') {
  67. const results = [];
  68. return readDirWalker(base, results, this.ignore, prefix)
  69. .return(results)
  70. .map(path => this._checkFileStatus(path))
  71. .map(file => this._processFile(file.type, file.path).return(file.path));
  72. }
  73. _checkFileStatus(path) {
  74. const { Cache, context: ctx } = this;
  75. const src = join(this.base, path);
  76. return Cache.compareFile(
  77. escapeBackslash(src.substring(ctx.base_dir.length)),
  78. () => getHash(src),
  79. () => stat(src)
  80. ).then(result => ({
  81. type: result.type,
  82. path
  83. }));
  84. }
  85. process(callback) {
  86. const { base, Cache, context: ctx } = this;
  87. return stat(base).then(stats => {
  88. if (!stats.isDirectory()) return;
  89. // Check existing files in cache
  90. const relativeBase = escapeBackslash(base.substring(ctx.base_dir.length));
  91. const cacheFiles = Cache.filter(item => item._id.startsWith(relativeBase)).map(item => item._id.substring(relativeBase.length));
  92. // Handle deleted files
  93. return this._readDir(base)
  94. .then(files => cacheFiles.filter(path => !files.includes(path)))
  95. .map(path => this._processFile(File.TYPE_DELETE, path));
  96. }).catch(err => {
  97. if (err && err.code !== 'ENOENT') throw err;
  98. }).asCallback(callback);
  99. }
  100. _processFile(type, path) {
  101. if (this._processingFiles[path]) {
  102. return Promise.resolve();
  103. }
  104. this._processingFiles[path] = true;
  105. const { base, File, context: ctx } = this;
  106. this.emit('processBefore', {
  107. type,
  108. path
  109. });
  110. return Promise.reduce(this.processors, (count, processor) => {
  111. const params = processor.pattern.match(path);
  112. if (!params) return count;
  113. const file = new File({
  114. source: join(base, path),
  115. path,
  116. params,
  117. type
  118. });
  119. return Reflect.apply(Promise.method(processor.process), ctx, [file])
  120. .thenReturn(count + 1);
  121. }, 0).then(count => {
  122. if (count) {
  123. ctx.log.debug('Processed: %s', magenta(path));
  124. }
  125. this.emit('processAfter', {
  126. type,
  127. path
  128. });
  129. }).catch(err => {
  130. ctx.log.error({err}, 'Process failed: %s', magenta(path));
  131. }).finally(() => {
  132. this._processingFiles[path] = false;
  133. }).thenReturn(path);
  134. }
  135. watch(callback) {
  136. if (this.isWatching()) {
  137. return Promise.reject(new Error('Watcher has already started.')).asCallback(callback);
  138. }
  139. const { base } = this;
  140. function getPath(path) {
  141. return escapeBackslash(path.substring(base.length));
  142. }
  143. return this.process().then(() => watch(base, this.options)).then(watcher => {
  144. this.watcher = watcher;
  145. watcher.on('add', path => {
  146. this._processFile(File.TYPE_CREATE, getPath(path));
  147. });
  148. watcher.on('change', path => {
  149. this._processFile(File.TYPE_UPDATE, getPath(path));
  150. });
  151. watcher.on('unlink', path => {
  152. this._processFile(File.TYPE_DELETE, getPath(path));
  153. });
  154. watcher.on('addDir', path => {
  155. let prefix = getPath(path);
  156. if (prefix) prefix += '/';
  157. this._readDir(path, prefix);
  158. });
  159. }).asCallback(callback);
  160. }
  161. unwatch() {
  162. if (!this.isWatching()) return;
  163. this.watcher.close();
  164. this.watcher = null;
  165. }
  166. isWatching() {
  167. return Boolean(this.watcher);
  168. }
  169. }
  170. function escapeBackslash(path) {
  171. // Replace backslashes on Windows
  172. return path.replace(/\\/g, '/');
  173. }
  174. function getHash(path) {
  175. const src = createReadStream(path);
  176. const hasher = createSha1Hash();
  177. const finishedPromise = new Promise((resolve, reject) => {
  178. src.once('error', reject);
  179. src.once('end', resolve);
  180. });
  181. src.on('data', chunk => { hasher.update(chunk); });
  182. return finishedPromise.then(() => hasher.digest('hex'));
  183. }
  184. function toRegExp(ctx, arg) {
  185. if (!arg) return null;
  186. if (typeof arg !== 'string') {
  187. ctx.log.warn('A value of "ignore:" section in "_config.yml" is not invalid (not a string)');
  188. return null;
  189. }
  190. const result = makeRe(arg);
  191. if (!result) {
  192. ctx.log.warn('A value of "ignore:" section in "_config.yml" can not be converted to RegExp:' + arg);
  193. return null;
  194. }
  195. return result;
  196. }
  197. function isIgnoreMatch(path, ignore) {
  198. return path && ignore && ignore.length && isMatch(path, ignore);
  199. }
  200. function readDirWalker(base, results, ignore, prefix) {
  201. if (isIgnoreMatch(base, ignore)) return Promise.resolve();
  202. return Promise.map(readdir(base).catch(err => {
  203. if (err && err.code === 'ENOENT') return [];
  204. throw err;
  205. }), async path => {
  206. const fullpath = join(base, path);
  207. const stats = await stat(fullpath);
  208. const prefixdPath = `${prefix}${path}`;
  209. if (stats.isDirectory()) {
  210. return readDirWalker(fullpath, results, ignore, `${prefixdPath}/`);
  211. }
  212. if (!isIgnoreMatch(fullpath, ignore)) {
  213. results.push(prefixdPath);
  214. }
  215. });
  216. }
  217. module.exports = Box;