|
| 1 | +/** |
| 2 | + * @file A webpack plugin to run esdoc on compile / recompile. |
| 3 | + */ |
| 4 | + |
| 5 | +const webpack = require('webpack'); |
| 6 | +const path = require('path'); |
| 7 | +const spawn = require('child_process').spawn; |
| 8 | +const fse = require('fs-extra'); |
| 9 | +const chalk = require('chalk'); |
| 10 | + |
| 11 | +const validateOptions = require('schema-utils'); |
| 12 | + |
| 13 | +// Schema for options object. |
| 14 | +const schema = { |
| 15 | + type: 'object', |
| 16 | + properties: { |
| 17 | + conf: { |
| 18 | + type: 'string', |
| 19 | + }, |
| 20 | + cwd: { |
| 21 | + type: 'string', |
| 22 | + }, |
| 23 | + preserveTmpFile: { |
| 24 | + type: 'boolean', |
| 25 | + }, |
| 26 | + showOutput: { |
| 27 | + type: 'boolean', |
| 28 | + } |
| 29 | + }, |
| 30 | +}; |
| 31 | + |
| 32 | +const isWindows = /^win/.test(process.platform); |
| 33 | + |
| 34 | +const PLUGIN_NAME = 'ESDocPlugin'; |
| 35 | + |
| 36 | +const ESDOC_FILES = isWindows ? [] : [ |
| 37 | + 'node_modules/.bin/esdoc', |
| 38 | + 'node_modules/esdoc/esdoc.js', |
| 39 | +]; |
| 40 | + |
| 41 | +/** |
| 42 | + * Look for files in directories. |
| 43 | + */ |
| 44 | +const lookupFile = (files, dirs) => { |
| 45 | + let found = null; |
| 46 | + |
| 47 | + [].concat(files).some(function (filename) { |
| 48 | + return [].concat(dirs).some(function (dirname) { |
| 49 | + var file = path.resolve(path.join(dirname, filename)); |
| 50 | + |
| 51 | + if (fse.existsSync(file)) { |
| 52 | + return found = file; |
| 53 | + } |
| 54 | + }); |
| 55 | + }); |
| 56 | + |
| 57 | + return found; |
| 58 | +}; |
| 59 | + |
| 60 | +const getLongestCommonSharedDirectory = (s) => { |
| 61 | + let k = s[0].Length; |
| 62 | + for (let i = 1; i < s.length; i++) { |
| 63 | + k = Math.Min(k, s[i].length); |
| 64 | + for (let j = 0; j < k; j++) { |
| 65 | + if (s[i][j] != s[0][j]) { |
| 66 | + k = j; |
| 67 | + break; |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + const fullPath = s[0].substring(0, k); |
| 72 | + return fullPath.substring(0, fullPath.lastIndexOf('/')); |
| 73 | +} |
| 74 | + |
| 75 | +/** |
| 76 | + * Reads the esdoc config |
| 77 | + * |
| 78 | + * @param {string} filepath - The path to the file. |
| 79 | + * @returns {any} |
| 80 | + */ |
| 81 | +const readConfigFile = (filepath) => { |
| 82 | + delete require.cache[filepath]; |
| 83 | + return require(filepath); |
| 84 | +}; |
| 85 | + |
| 86 | +/** |
| 87 | + * Converts milliseconds to minutes:seconds. |
| 88 | + * |
| 89 | + * @param {number} millis - A millisecond value. |
| 90 | + * @returns {string} - A string in the format mm:ss. |
| 91 | + */ |
| 92 | +const millisToMinutesAndSeconds = (millis) => { |
| 93 | + var minutes = Math.floor(millis / 60000); |
| 94 | + var seconds = ((millis % 60000) / 1000).toFixed(0); |
| 95 | + return minutes + ':' + (seconds < 10 ? '0' : '') + seconds; |
| 96 | +}; |
| 97 | + |
| 98 | +/** |
| 99 | + * Defines the main ESDocPlugin. |
| 100 | + * |
| 101 | + * @class |
| 102 | + * @type {WebpackPlugin} |
| 103 | + * @todo Running webpack in watch mode causes compile to happen twice. |
| 104 | + * @todo Validate constructor options. |
| 105 | + * @todo Cleanly merge options passed to the constructor with default options. Lodash's merge is nice, but I don't want another dep. |
| 106 | + * @todo Test it. |
| 107 | + * @todo Try setting some params from the webpack plugin instance. |
| 108 | + * @todo Handle cases where we can't find the config file. |
| 109 | + */ |
| 110 | +module.exports = class Plugin { |
| 111 | + constructor(opts = {source: './src', destination: './docs'}) { |
| 112 | + validateOptions(schema, opts, 'ESDoc webpack plugin'); |
| 113 | + const defaultOptions = { |
| 114 | + conf: '.esdoc.json', // Default config file name. |
| 115 | + cwd: opts.cwd || './', // Default path for lookup. |
| 116 | + preserveTmpFile: true, // Keep the generated temporary settings file? |
| 117 | + showOutput: false, // Show all the output from esdoc? |
| 118 | + // esdoc option defaults, just in case. |
| 119 | + source: './src', |
| 120 | + destination: './docs', |
| 121 | + excludes: ['\\.config\\.js', '\\.babel\\.js'], |
| 122 | + plugins: [{ |
| 123 | + name: 'esdoc-standard-plugin', |
| 124 | + }], |
| 125 | + }; |
| 126 | + |
| 127 | + // Merge options |
| 128 | + // opts passed to the constructor will override default values. |
| 129 | + this.options = {...defaultOptions, ...opts}; |
| 130 | + |
| 131 | + if (this.options.showOutput) { |
| 132 | + console.log(chalk.yellow('ESDocPlugin:'), 'Options', this.options); |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + apply(compiler) { |
| 137 | + const self = this; |
| 138 | + const options = self.options; |
| 139 | + const cwd = process.cwd(); |
| 140 | + const givenDirectory = options.cwd; |
| 141 | + let preserveTmpFile = options.preserveTmpFile; |
| 142 | + let esdocConfig = path.resolve(givenDirectory, options.conf); |
| 143 | + const esdocConfigDir = path.dirname(esdocConfig); |
| 144 | + const files = []; |
| 145 | + let cmd; |
| 146 | + let obj = {}; |
| 147 | + let tmpFile; |
| 148 | + let esdocArgs; |
| 149 | + let esdoc; |
| 150 | + let esdocErrors = []; |
| 151 | + |
| 152 | + compiler.hooks.watchRun.tapAsync(PLUGIN_NAME, (compiler, callback) => { |
| 153 | + console.log(chalk.yellow('ESDocPlugin'), chalk.magenta('Watching for changes...')); |
| 154 | + callback(); |
| 155 | + }); |
| 156 | + |
| 157 | + const promiseEsdoc = (esdoc, cmd, esdocArgs, esdocConfigDir, esdocErrors, tmpFile) => new Promise((resolve, reject) => { |
| 158 | + esdoc = spawn(cmd, esdocArgs, { |
| 159 | + cwd: esdocConfigDir, |
| 160 | + }); |
| 161 | + if (obj.showOutput) { |
| 162 | + // Collect the socket output from esdoc, turning the buffer into something readable. |
| 163 | + console.log(chalk.yellow('ESDocPlugin:'), 'Beginning output.'); |
| 164 | + let received = ''; |
| 165 | + esdoc.stdout.on('data', (data) => { |
| 166 | + received += data; |
| 167 | + const messages = received.split('\n'); |
| 168 | + if (messages.length > 1) { |
| 169 | + let printed = ''; |
| 170 | + for (let message of messages) { |
| 171 | + if (message !== '') { |
| 172 | + let split = (message.toString().split(':')); |
| 173 | + console.log(`${chalk.blue(split[0])}: ${chalk.green(split[1])}`); |
| 174 | + received = ''; |
| 175 | + } |
| 176 | + } |
| 177 | + } |
| 178 | + }); |
| 179 | + } |
| 180 | + esdoc.stderr.on('data', (data) => esdocErrors.push(data.toString())); |
| 181 | + esdoc.on('close', (closeCode) => { |
| 182 | + // Remove that tmp file if we have one and we aren't keeping it. |
| 183 | + if (tmpFile && !preserveTmpFile) { |
| 184 | + console.log(chalk.yellow('ESDocPlugin:'), 'Removing temporary esdoc config file...'); |
| 185 | + fse.unlinkSync(tmpFile); |
| 186 | + tmpFile = null; |
| 187 | + } |
| 188 | + if (esdocErrors.length > 0) { |
| 189 | + esdocErrors.forEach((value) => console.error(value)); |
| 190 | + reject(new Error(chalk.yellow('ESDocPlugin:'), 'Exited with code ' + code)); |
| 191 | + } else { |
| 192 | + console.log(chalk.yellow('ESDocPlugin:'), 'Emitted files to output directory.'); |
| 193 | + resolve(true); |
| 194 | + } |
| 195 | + }); |
| 196 | + }); |
| 197 | + |
| 198 | + compiler.hooks.emit.tapAsync(PLUGIN_NAME, (compilation, callback) => { |
| 199 | + console.log(chalk.yellow('ESDocPlugin:'), 'Compiling...'); |
| 200 | + console.log('EMITTING'); |
| 201 | + |
| 202 | + // Look for esdoc and when we find it, set it to cmd. |
| 203 | + cmd = lookupFile(ESDOC_FILES, [ |
| 204 | + // config dir |
| 205 | + esdocConfigDir, |
| 206 | + // given dir |
| 207 | + givenDirectory, |
| 208 | + // called from |
| 209 | + cwd, |
| 210 | + // Here |
| 211 | + __dirname, |
| 212 | + ]); |
| 213 | + // Wait a second... is esdoc installed? |
| 214 | + if (!cmd) { |
| 215 | + callback(new Error(chalk.yellow('ESDocPlugin:'), 'esdoc was not found.')); |
| 216 | + } |
| 217 | + // See if esdocConfig exists, if it does, set it to obj, otherwise have an exception. |
| 218 | + if (fse.existsSync(esdocConfig)) { |
| 219 | + try { |
| 220 | + obj = readConfigFile(esdocConfig); |
| 221 | + } catch (exception) { |
| 222 | + callback(exception); |
| 223 | + return; |
| 224 | + } |
| 225 | + } |
| 226 | + |
| 227 | + // If we have a config file, use it. Otherwise handle it. |
| 228 | + if (obj.source && obj.includes) { |
| 229 | + console.log(chalk.yellow('ESDocPlugin:'), 'Pulling data from the configuration file.'); |
| 230 | + // Merge the configuration file with the options object sent from webpack. |
| 231 | + // If a user decided to set some options when they called `new Plugin()`, |
| 232 | + // and still pointed to a config file, we can assume the instance settings |
| 233 | + // they passed should take priority. |
| 234 | + // Some of the keys that end up in here may not be useful. |
| 235 | + obj = {...obj, ...options}; // lodash would be better for this because it can do deep merges, but I just don't want it. |
| 236 | + } |
| 237 | + else { |
| 238 | + console.log(chalk.yellow('ESDocPlugin:'), 'Provided configuration either not found or does not contain an includes key. Generating from the bundles.') |
| 239 | + // If our options object doesn't have includes, let's generate them from the bundles. |
| 240 | + compilation.fileDependencies.forEach((filepath, i) => { |
| 241 | + // Excludes this expression from out file path collection. |
| 242 | + var exception = /\/node_modules\//.test(filepath); |
| 243 | + var inclusion = /index.js$/.test(filepath); |
| 244 | + |
| 245 | + // Collect all our js files. |
| 246 | + if (!exception && inclusion) { |
| 247 | + files.push(filepath); |
| 248 | + } |
| 249 | + }); |
| 250 | + |
| 251 | + // Get the shared parent directory of all our files, that's the src. |
| 252 | + obj.source = getLongestCommonSharedDirectory(files); |
| 253 | + obj = {...obj, ...options}; |
| 254 | + } |
| 255 | + |
| 256 | + // Since we're generating config, we'll store it in a tmp file to pass to the esdoc executable. |
| 257 | + tmpFile = esdocConfig + '.tmp'; |
| 258 | + console.log(chalk.yellow('ESDocPlugin:'), 'Writing temporary file at: ', tmpFile); |
| 259 | + fse.writeFileSync(tmpFile, JSON.stringify(obj)); |
| 260 | + esdocConfig = tmpFile; |
| 261 | + |
| 262 | + console.log(chalk.yellow('ESDocPlugin:'), 'Using esdoc located at', cmd); |
| 263 | + |
| 264 | + // Esdoc doesn't actually have a lot of cli arguments. |
| 265 | + // Here we just point it to our config file. |
| 266 | + esdocArgs = ['-c', esdocConfig]; |
| 267 | + |
| 268 | + callback(); |
| 269 | + }); |
| 270 | + |
| 271 | + // Report when finished. |
| 272 | + compiler.hooks.done.tap(PLUGIN_NAME, (stats) => { |
| 273 | + // @TODO: Really this run of esdoc as a child process should probably happen in the emit hook, |
| 274 | + // but if it's there, watching makes emit trigger twice on startup. I'm guessing the |
| 275 | + // reason this happens is that emit finishes before the subprocess has totally exited, |
| 276 | + // so when it finally does end, the esdoc process creates/modifies files in the plugin |
| 277 | + // output directory get created and compilation starts all over again. |
| 278 | + // I need a way to ignore the output files or directory for this plugin during watch, we |
| 279 | + // don't care if something happens there. |
| 280 | + promiseEsdoc(esdoc, cmd, esdocArgs, esdocConfigDir, esdocErrors, tmpFile) |
| 281 | + .then(response => { |
| 282 | + console.log(chalk.yellow('ESDocPlugin:'), 'Finished compiling.'); |
| 283 | + console.log(chalk.yellow('ESDocPlugin:'), 'Total run time ', chalk.green(millisToMinutesAndSeconds(stats.endTime - stats.startTime))); |
| 284 | + }) |
| 285 | + }); |
| 286 | + |
| 287 | + |
| 288 | + } |
| 289 | +}; |
0 commit comments