UNPKG

broccoli-concat

Version:

Fast, good-enough concatenation with source maps.

275 lines (224 loc) 7.76 kB
'use strict'; const Plugin = require('broccoli-plugin'); const FSTree = require('fs-tree-diff'); const path = require('path'); const fs = require('fs-extra'); const { merge, omit, uniq } = require('lodash'); const ensurePosix = require('ensure-posix-path'); const StatsOutput = require('./lib/stats-output'); const ensureNoGlob = require('./lib/utils/ensure-no-glob'); const isDirectory = require('./lib/utils/is-directory'); const makeIndex = require('./lib/utils/make-index'); let id = 0; module.exports = class Concat extends Plugin { constructor(inputNode, options, Strategy) { if (!options || !options.outputFile) { throw new Error('the outputFile option is required'); } id++; let inputNodes = Concat.inputNodesForConcatStats(inputNode, id, options.outputFile); super(inputNodes, { annotation: options.annotation, name: (Strategy.name || 'Unknown') + 'Concat', persistentOutput: true }); this.id = id; if (Strategy === undefined) { throw new TypeError('Concat requires a concat Strategy'); } this.Strategy = Strategy; this.sourceMapConfig = omit(options.sourceMapConfig || {}, 'enabled'); this.allInputFiles = uniq([].concat(options.headerFiles || [], options.inputFiles || [], options.footerFiles || [])); this.inputFiles = options.inputFiles; this.outputFile = options.outputFile; this.allowNone = options.allowNone; this.header = options.header; this.headerFiles = options.headerFiles; this._headerFooterFilesIndex = makeIndex(options.headerFiles, options.footerFiles); this.footer = options.footer; this.footerFiles = options.footerFiles; this.separator = (options.separator != null) ? options.separator : '\n'; this.contentLimit = options.contentLimit; ensureNoGlob('headerFiles', this.headerFiles); ensureNoGlob('footerFiles', this.footerFiles); this._lastTree = FSTree.fromEntries([]); this._hasBuilt = false; this._hasStatsOutput = false; this.encoderCache = {}; } static inputNodesForConcatStats(inputNode, id, outputFile) { let dir = Concat.concatStatsPath(); return [ new StatsOutput(inputNode, { dir, name: id + '-' + path.basename(outputFile) }) ]; } static concatStatsPath() { return process.env.CONCAT_STATS_PATH || `${process.cwd()}/concat-stats-for`; } calculatePatch() { let currentTree = this.getCurrentFSTree(); let patch = this._lastTree.calculatePatch(currentTree); this._lastTree = currentTree; return patch; } build() { let patch = this.calculatePatch(); // We skip building if this is a rebuild with a zero-length patch // and the request for stats output has not changed since the last build if (patch.length === 0 && this._hasBuilt && (!process.env.CONCAT_STATS || this._hasStatsOutput)) { return; } this._hasBuilt = true; if (process.env.CONCAT_STATS) { fs.mkdirpSync(Concat.concatStatsPath()); this._hasStatsOutput = true; } if (this.Strategy.isPatchBased) { return this._doPatchBasedBuild(patch); } else { return this._doLegacyBuild(); } } _doPatchBasedBuild(patch) { if (!this.concat) { let options = merge(this.sourceMapConfig, { separator: this.separator, header: this.header, headerFiles: this.headerFiles, footerFiles: this.footerFiles, footer: this.footer, contentLimit: this.contentLimit, baseDir: this.inputPaths[0] }); options.fs = this.input; this.concat = new this.Strategy(options); } for (let i = 0; i < patch.length; i++) { let operation = patch[i]; let method = operation[0]; let file = operation[1]; switch (method) { case 'create': this.concat.addFile(file, this.input.readFileSync(file, 'utf-8')); break; case 'change': this.concat.updateFile(file, this.input.readFileSync(file, 'utf-8')); break; case 'unlink': this.concat.removeFile(file); break; } } let content = this.concat.result(); // If content is undefined, then we the concat had no input files if (content === undefined) { if (!this.allowNone) { throw new Error('Concat: nothing matched [' + this.inputFiles + ']'); } else { content = ''; } } if (process.env.CONCAT_STATS) { let fileSizes = this.concat.fileSizes(); let statsPath = Concat.concatStatsPath(); let outputPath = path.join(statsPath, `${this.id}-${path.basename(this.outputFile)}.json`); fs.mkdirpSync(statsPath); fs.writeFileSync(outputPath, JSON.stringify({ outputFile: this.outputFile, sizes: fileSizes }, null, 2)); } this.output.outputFileSync(this.outputFile.replace(/^\//,''), content); } _doLegacyBuild() { let separator = this.separator; let firstSection = true; let outputFile = path.join(this.outputPath, this.outputFile); fs.mkdirpSync(path.dirname(outputFile)); let options = merge(this.sourceMapConfig, { outputFile: outputFile, baseDir: this.inputPaths[0], cache: this.encoderCache, pluginId: this.id }); options.fs = this.input; this.concat = new this.Strategy(options); return this.concat.end(concat => { function beginSection() { if (firstSection) { firstSection = false; } else { concat.addSpace(separator); } } if (this.header) { beginSection(); concat.addSpace(this.header); } if (this.headerFiles) { this.headerFiles.forEach(file => { beginSection(); concat.addFile(file); }); } this.addFiles(beginSection); if (this.footerFiles) { this.footerFiles.forEach(file => { beginSection(); concat.addFile(file); }); } if (this.footer) { beginSection(); concat.addSpace(this.footer + '\n'); } }, this); } getCurrentFSTree() { return FSTree.fromEntries(this.listEntries()); } listEntries() { // If we have no inputFiles at all, use undefined as the filter to return // all files in the inputDir. let filter = this.allInputFiles.length ? this.allInputFiles : undefined; return this.input.entries('./', filter); } /** * Returns the full paths for any matching inputFiles. */ listFiles() { let inputDir = this.inputPaths[0]; return this.listEntries().map(function(entry) { return ensurePosix(path.join(inputDir, entry.relativePath)); }); } addFiles(beginSection) { let headerFooterFileOverlap = false; let posixInputPath = ensurePosix(this.inputPaths[0]); let files = this.listFiles().filter(file => { let relativePath = file.replace(posixInputPath + '/', ''); // * remove inputFiles that are already contained within headerFiles and footerFiles // * allow duplicates between headerFiles and footerFiles if (this._headerFooterFilesIndex[relativePath] === true) { headerFooterFileOverlap = true; return false; } return !isDirectory(file); }, this); // raise IFF: // * headerFiles or footerFiles overlapped with inputFiles // * nothing matched inputFiles if (headerFooterFileOverlap === false && files.length === 0 && !this.allowNone) { throw new Error('Concat: nothing matched [' + this.inputFiles + ']'); } files.forEach(file => { beginSection(); this.concat.addFile(file.replace(posixInputPath + '/', '')); }, this); } };