UNPKG

45.8 kBJavaScriptView Raw
1'use strict';
2
3const fs = require('fs-extra');
4const path = require('path');
5const crypto = require('crypto');
6const zlib = require('zlib');
7const EventEmitter = require('events').EventEmitter;
8const util = require('util');
9
10const _ = require('underscore');
11const async = require('async');
12const dir = require('node-dir');
13const uglifyjs = require('uglify-js');
14const CleanCss = require('clean-css');
15const less = require('less');
16const request = require('request');
17
18const S3 = require('./s3');
19
20const ONE_YEAR_IN_SECONDS = 31536000;
21const THIRTY_DAYS_IN_SECONDS = 2592000;
22const SEVEN_DAYS_IN_SECONDS = 604800;
23
24/*
25const CONTENT_BUCKET_URI = '//'+env.AMAZON_S3_BUCKET+'.s3.amazonaws.com/';
26const CONTENT_BUCKET_FOLDER = 'content';*/
27
28function AssetProcessor(config) {
29
30 let baseRoot;
31 let currRelativeRoot;
32
33 this.config = config;
34
35 if (config.s3) {
36 this.s3 = new S3(config.s3.bucket, config.s3.key, config.s3.secret);
37 if (config.s3.cloudfrontMapping) {
38 this.s3.setCloudfrontMapping(config.s3.cloudfrontMapping);
39 }
40 }
41
42 // set default extensions for js, css, and images
43
44 if (this.config.targets.javascripts && !this.config.targets.javascripts.extensions) {
45 this.config.targets.javascripts.extensions = ['.js'];
46 }
47 if (this.config.targets.stylesheets && !this.config.targets.stylesheets.extensions) {
48 this.config.targets.stylesheets.extensions = ['.css'];
49 }
50 if (this.config.targets.images && !this.config.targets.images.extensions) {
51 this.config.targets.images.extensions = ['.ico', '.png','.jpg','.jpeg','.gif','.bmp','.svg'];
52 }
53
54 // figure out and normalize the relevant roots we are using
55
56 if (!this.config.root) {
57 throw new Error('Configuration root is required');
58 } else if (!fs.existsSync(this.config.root)) {
59 throw new Error('Invalid configuration root');
60 }
61
62 this.config.root = path.normalize(this.config.root); //full path that is the base root of the project; target roots are relative to this
63
64 baseRoot = this.config.root;
65
66 currRelativeRoot = this.config.targets.javascripts && this.config.targets.javascripts.root; //image target root (if any)
67 this.config.javascriptsRoot = path.normalize(currRelativeRoot ? path.resolve(baseRoot, currRelativeRoot) : baseRoot); //full path to the local root for image assets
68
69 currRelativeRoot = this.config.targets.stylesheets && this.config.targets.stylesheets.root; //image target root (if any)
70 this.config.stylesheetsRoot = path.normalize(currRelativeRoot ? path.resolve(baseRoot, currRelativeRoot) : baseRoot); //full path to the local root for image assets
71
72 currRelativeRoot = this.config.targets.images && this.config.targets.images.root; //image target root (if any)
73 this.config.imagesRoot = path.normalize(currRelativeRoot ? path.resolve(baseRoot, currRelativeRoot) : baseRoot); //full path to the local root for image assets
74 this.config.imagesRootRelative = path.relative(this.config.root, this.config.imagesRoot);
75
76 currRelativeRoot = this.config.targets.extras && this.config.targets.extras.root; //extra target root (if any)
77 this.config.extrasRoot = path.normalize(currRelativeRoot ? path.resolve(baseRoot, currRelativeRoot) : baseRoot); //full path to the local root for extra assets
78 this.config.extrasRootRelative = path.relative(this.config.root, this.config.extrasRoot);
79
80 // Some other options
81 this.forceCdnUpdate = this.config.forceCdnUpdate;
82}
83
84util.inherits(AssetProcessor, EventEmitter);
85
86/**
87 * Gets array of relevant javascript file paths based on the AssetProcessor's configuration
88 * @param {function} callback function(err, files)
89 */
90AssetProcessor.prototype.getJavaScriptFiles = function (normalizedFullPath, callback) {
91 _getFilesRelativeToRoot(this.config.root, this.config.javascriptsRoot, this.config.targets.javascripts, normalizedFullPath, callback);
92};
93
94/**
95 * Gets array of relevant javascript file paths based on the AssetProcessor's configuration
96 * @param {function} callback function(err, files)
97 */
98AssetProcessor.prototype.getCssFiles = function (normalizedFullPath, callback) {
99 _getFilesRelativeToRoot(this.config.root, this.config.stylesheetsRoot, this.config.targets.stylesheets, normalizedFullPath, callback);
100};
101
102/**
103 * Gets array of relevant image file paths based on the AssetProcessor's configuration
104 * @param {function} callback function(err, files)
105 */
106AssetProcessor.prototype.getImageFiles = function (normalizedFullPath, callback) {
107 _getFilesRelativeToRoot(this.config.root, this.config.imagesRoot, this.config.targets.images, normalizedFullPath, callback);
108};
109
110/**
111 * Gets array of relevant extra file paths based on the AssetProcessor's configuration
112 * @param {function} callback function(err, files)
113 */
114AssetProcessor.prototype.getExtraFiles = function (normalizedFullPath, callback) {
115 _getFilesRelativeToRoot(this.config.root, this.config.extrasRoot, this.config.targets.extras, normalizedFullPath, callback);
116};
117
118function _getFilesRelativeToRoot(mainRoot, activeRoot, targets, normalizedFullPath, callback) {
119 const relative = path.relative(mainRoot, activeRoot);
120
121 if (typeof normalizedFullPath === 'function') {
122 callback = normalizedFullPath;
123 normalizedFullPath = false;
124 }
125
126
127 _getFiles(targets, activeRoot, normalizedFullPath, function(err, files) {
128 if (normalizedFullPath) {
129 // if normalized, no need to modify paths
130 callback(err, files);
131 } else {
132 // if not normalized, make files relative to main root
133 // then replace windows "\\" path separator with "/"
134 const relativeFiles = _.map(files || [], function (file) {
135 return path.join(relative, file).replace(/\\/gi,'/');
136 });
137
138 callback(err, relativeFiles);
139
140 }
141 });
142}
143
144/**
145 * Searches for any .less files associated with the css file configuration and compiles them.
146 * @param callback function(err, cssFiles)
147 */
148AssetProcessor.prototype.compileLessFiles = function(callback) {
149 const self = this;
150 const lessTargetConfig = JSON.parse(JSON.stringify(this.config.targets.stylesheets)); //shallow copy
151 const cssFiles = [];
152 // for now lets keep folder structure but only look at .less files
153 lessTargetConfig.extensions = ['.less'];
154
155 async.auto({
156 getFiles: [function(next) {
157 _getFiles(lessTargetConfig, self.config.stylesheetsRoot, true, next);
158 }],
159 processFiles: ['getFiles', function(next, results) {
160
161 // filter out any directories
162 const files = results.getFiles.filter(function(file) {
163 return path.extname(file).toLowerCase() === '.less';
164 });
165
166 async.eachSeries(files, function(file, eachNext) {
167
168 async.auto({
169 readFile: [function(subNext) {
170 fs.readFile(file, subNext);
171 }],
172 render: ['readFile', function(subNext, results) {
173 less.render(results.readFile.toString(), {
174 filename: path.resolve(file)
175 }, subNext);
176 }],
177 writeFile: ['render', function(subNext, results) {
178 const cssFile = file.replace(/\.less$/i, '.css');
179 fs.writeFile(cssFile, results.render, function(err) {
180 if (!err) {
181 cssFiles.push(cssFile);
182 }
183 subNext(err);
184 });
185 }]
186 }, function(err) {
187 eachNext(err);
188 });
189 }, function(err) {
190 next(err);
191 });
192 }]
193 }, function(err) {
194 callback(err, cssFiles);
195 });
196};
197
198/**
199 * Uglifies and compresses JavaScript
200 * @param excludeSourceMap
201 * @param callback
202 */
203AssetProcessor.prototype.processJavaScript = function(excludeSourceMap, callback) {
204 const self = this;
205
206 if (typeof excludeSourceMap === 'function') {
207 callback = excludeSourceMap;
208 excludeSourceMap = false;
209 }
210
211 async.auto({
212 getFiles: [function(next) {
213 self.getJavaScriptFiles(true, next);
214 }],
215 getPath: ['getFiles', function(next, results) {
216 _getJavaScriptPath(results.getFiles, next);
217 }],
218 minify: ['getPath', function(next, results) {
219 const jsFiles = results.getFiles;
220 let uglifyResult = {};
221 let err;
222 const mapPath = results.getPath.replace('.js','.map');
223 const publicMapPath = self.s3 ? self.s3.urlWithBucket(mapPath) : mapPath;
224 const uglifyOptions = excludeSourceMap ? {} : {outSourceMap: publicMapPath};
225
226 self.emit('minifyStarted', {type: 'js', files: jsFiles});
227
228 if (!jsFiles.length) {
229 next(null, {});
230 } else {
231 try {
232 uglifyResult = uglifyjs.minify(jsFiles, uglifyOptions);
233 } catch (uglifyError) {
234 err = uglifyError;
235 }
236
237 next(err, { js: uglifyResult && uglifyResult.code, map: uglifyResult.map });
238 }
239
240
241 }]
242 }, callback);
243}
244
245
246/**
247 * Saves processed JavaScript locally
248 * @param callback
249 */
250AssetProcessor.prototype.saveJavaScriptToFile = function(callback) {
251
252 const self = this;
253
254 async.auto({
255 getSource: [function (next) {
256 self.processJavaScript(false, next);
257 }],
258 saveFile: ['getSource', function (next, results) {
259
260 let relativeUrl = "";
261 const source = results.getSource.minify.js;
262
263 if (source) {
264 const fullLocalPath = self.config.javascriptsRoot + '/' + results.getSource.getPath;
265 relativeUrl = '/' + results.getSource.getPath;
266
267 fs.outputFileSync(fullLocalPath, source);
268 }
269
270 next(null, relativeUrl);
271 }],
272 }, function(err, results) {
273 const result = {
274 jsUrl: results && results.saveFile
275 };
276 callback(err, result);
277 });
278
279};
280
281/**
282 * Uglifies, compresses, and uploads the JavaScript to S3
283 * @param {boolean=} excludeSourceMap [optional] if true source map will not be generated and uploaded
284 * @param {function} callback function(err, uploadedUrl)
285 */
286AssetProcessor.prototype.uploadJavaScriptToCdn = function(excludeSourceMap, callback) {
287
288 const self = this;
289
290 if (typeof excludeSourceMap === 'function') {
291 callback = excludeSourceMap;
292 excludeSourceMap = false;
293 }
294
295 async.auto({
296 getSource: [function(next) {
297 self.processJavaScript(excludeSourceMap, next);
298 }],
299 compressJs: ['getSource', function(next, results) {
300 zlib.gzip(results.getSource.minify.js, next);
301 }],
302 uploadJs: ['compressJs', function(next, results) {
303 const jsFiles = results.getSource.getFiles;
304 const gzip = results.compressJs;
305 const targetPath = results.getSource.getPath;
306 const headers = {
307 'x-amz-acl': 'public-read',
308 'Content-Type': 'application/x-javascript',
309 'Content-Encoding': 'gzip',
310 'Content-Length': gzip.length,
311 'Cache-Control': 'public, max-age=' + ONE_YEAR_IN_SECONDS
312 };
313 self.emit('minifyEnded', {type: 'js', files: jsFiles});
314 self.emit('uploadStarted', {type: 'js', target: targetPath, source: 'memory'});
315 self.s3.putBuffer(gzip, targetPath, headers, next);
316 }],
317 uploadMap: ['getSource', function(next, results) {
318 const mapPath = results.getSource.getPath.replace('.js','.map');
319 if (!excludeSourceMap) {
320 self.s3.putBuffer(results.getSource.minify.map, mapPath, next);
321 } else {
322 next();
323 }
324 }],
325 uploadCompleted: ['uploadJs', function(next, results) {
326 const targetPath = results.getSource.getPath;
327 self.emit('uploadEnded', {type: 'js', target: targetPath, source: 'memory', url: results.uploadJs});
328 next();
329 }]
330 }, function(err, results) {
331 callback(err, results && results.uploadJs);
332 });
333};
334
335AssetProcessor.prototype.processCss = function(callback) {
336
337 const self = this;
338
339 async.auto({
340 getFiles: [function(next) {
341 self.getCssFiles(true, next);
342 }],
343 getPath: ['getFiles', function(next, results) {
344 _getCssPath(results.getFiles, next);
345 }],
346 cleanCss: ['getFiles', function(next, results) {
347
348 const files = results.getFiles;
349 let css = '';
350
351 self.emit('minifyStarted', {type: 'css', files: files});
352
353 async.eachSeries(files, function(file, eachNext) {
354 let currCss = ''+fs.readFileSync(file);
355 currCss = new CleanCss({noAdvanced: true, noRebase: true}).minify(currCss);
356 currCss = _rebaseUrls(self, file, currCss); //we do our own special rebasing
357 css += currCss+' \n';
358 eachNext();
359 }, function(err) {
360 next(err, css);
361 });
362 }]
363 }, callback);
364};
365
366AssetProcessor.prototype.saveCssToFile = function(callback) {
367
368 const self = this;
369
370 async.auto({
371 getSource: [function(next) {
372 self.processCss(next)
373 }],
374 saveFile: ['getSource', function (next, results) {
375
376 var relativeUrl = "";
377 const source = results.getSource.cleanCss;
378
379 if (source) {
380 const fullLocalPath = self.config.stylesheetsRoot + '/' + results.getSource.getPath;
381 relativeUrl = '/' + results.getSource.getPath;
382
383 fs.outputFileSync(fullLocalPath, source);
384 }
385
386 next(null, relativeUrl);
387 }],
388 }, function(err, results) {
389 callback(err, {
390 cssUrl: results && results.saveFile
391 });
392 });
393
394};
395
396/**
397 * Cleans up CSS, combines into one file, compresses it, and uploads it to S3
398 * @param {function} callback function(err, cssUrl)
399 */
400AssetProcessor.prototype.uploadCssToCdn = function(callback) {
401
402 const self = this;
403
404 async.auto({
405 getSource: [function(next) {
406 self.processCss(next)
407 }],
408 compressCss: ['getSource', function(next, results) {
409 zlib.gzip(results.getSource.cleanCss, next);
410 }],
411 uploadCss: ['compressCss', function(next, results) {
412 const cssFiles = results.getSource.getFiles;
413 const gzip = results.compressCss;
414 const targetPath = results.getSource.getPath;
415 const headers = {
416 'x-amz-acl': 'public-read',
417 'Content-Type': 'text/css',
418 'Content-Encoding': 'gzip',
419 'Content-Length': gzip.length,
420 'Cache-Control': 'public, max-age=' + ONE_YEAR_IN_SECONDS
421 };
422
423 self.emit('minifyEnded', {type: 'css', files: cssFiles});
424 self.emit('uploadStarted', {type: 'css', target: targetPath, source: 'memory'});
425 self.s3.putBuffer(gzip, targetPath, headers, next);
426 }],
427 uploadCompleted: ['uploadCss', function(next, results) {
428 const targetPath = results.getSource.getPath;
429 self.emit('uploadEnded', {type: 'css', target: targetPath, source: 'memory', url: results.uploadCss});
430 next();
431 }]
432 }, function(err, results) {
433 callback(err, results && results.uploadCss);
434 });
435};
436
437AssetProcessor.prototype.processImages = function(callback) {
438
439 const self = this;
440
441 async.auto({
442 getFiles: [function(next) {
443 self.getImageFiles(true, next);
444 }],
445 getFolder: [function(next) {
446 _getImageFolder(self.config.imagesRootRelative, next);
447 }],
448 }, callback);
449};
450
451/**
452 * Uploads images to CDN, maintain same directory structure relative to image root
453 * @param {function} callback function(err, imageFolderUri)
454 */
455AssetProcessor.prototype.uploadImagesToCdn = function(callback) {
456
457 const self = this;
458
459 async.auto({
460 getFiles: [function(next) {
461 self.getImageFiles(true, next);
462 }],
463 getFolder: [function(next) {
464 _getImageFolder(self.config.imagesRootRelative, next);
465 }],
466 uploadImages: ['getFiles', 'getFolder', function(next, results) {
467 _wrapUploadEmitter(self, _uploadRelativeToRoot(results.getFiles, self.config.imagesRoot, results.getFolder, self.s3, next), 'image');
468 }]
469 }, function(err, results) {
470 callback(err, results && results.uploadImages);
471 });
472};
473
474/**
475 * Uploads extras to CDN, maintain same directory structure relative to extras root
476 * @param {function} callback function(err, imageFolderUri)
477 */
478AssetProcessor.prototype.uploadExtrasToCdn = function(callback) {
479
480 const self = this;
481
482 async.auto({
483 getFiles: [function(next) {
484 self.getExtraFiles(true, next);
485 }],
486 getFolder: [function(next) {
487 _getExtrasFolder(self.config.extrasRootRelative, next);
488 }],
489 uploadExtras: ['getFiles', 'getFolder', function(next, results) {
490 _wrapUploadEmitter(self, _uploadRelativeToRoot(results.getFiles, self.config.extrasRoot, results.getFolder, self.s3, next), 'extra');
491 }]
492 }, function(err, results) {
493 callback(err, results && results.uploadExtras);
494 });
495};
496
497/**
498 * Imports latest stylesheets
499 * @param callback {function} function(err, numImported)
500 */
501AssetProcessor.prototype.importLatestStylesheets = function(callback) {
502
503 const self = this;
504
505 const gitToken = self.config.git && self.config.git.token;
506 const imports = self.config.targets && self.config.targets.stylesheets && self.config.targets.stylesheets.imports || {};
507
508 const destDir = path.resolve(self.config.stylesheetsRoot, imports.destination || '');
509 const sources = imports.sources || [];
510 const mappings = imports.mappings || {};
511 let numImported = 0;
512
513 if (!fs.existsSync(destDir)) {
514 console.log('Creating import directory '+destDir);
515 fs.mkdirsSync(destDir);
516 }
517
518 console.log('Importing '+sources.length+' css files to '+destDir);
519 if (Object.keys(mappings).length) {
520 console.log('Will be applying the following remappings:', mappings);
521 }
522
523 async.eachSeries(sources, function(source, eachNext) {
524 const destPath = path.resolve(destDir, path.basename(source));
525 console.log('Importing '+source+' to '+destPath);
526 _importFromGit(source, destPath, gitToken, function(err) {
527 if (!err) {
528 _applyImportMappings(destPath, mappings, function(err) {
529 if (!err) {
530 numImported += 1;
531 }
532 eachNext(err);
533 });
534 } else {
535 console.error(err);
536 eachNext(err);
537 }
538 });
539 }, function(err) {
540 console.log('Imported '+numImported+' of '+sources.length+' stylesheets');
541 callback(err, numImported);
542 });
543};
544
545/**
546 * Helper function that listens for and propagates uploadStarted and uploadedEnded events
547 * @param {EventEmitter} parentEmitter the parent emitter to propagate from (e.g. AssetProcessor's "this" scope)
548 * @param {EventEmitter} childEmitter the child emitter to listen to
549 * @param {string} type the type of file being uploaded by the childEmitter
550 */
551function _wrapUploadEmitter(parentEmitter, childEmitter, type) {
552 childEmitter.on('uploadStarted', function(ev) {
553 parentEmitter.emit('uploadStarted', {type: type, source: ev.source, target: ev.target});
554 });
555 childEmitter.on('uploadEnded', function(ev) {
556 parentEmitter.emit('uploadEnded', {type: type, source: ev.source, target: ev.target, url: ev.url});
557 });
558}
559
560/**
561 * Ensures latest assets are processed and uploaded
562 * @param {function} callback function(err, results)
563 * @return {object} result
564 * @return {object} result.ensureJs
565 * @return {object} result.ensureCss
566 * @return {object} result.ensureImages
567 * @return {object} result.ensureExtras
568 */
569AssetProcessor.prototype.ensureAssets = function(callback) {
570
571 const self = this;
572
573 //note: we use artificial dependencies to limit concurrent S3 usage
574
575 async.auto({
576 ensureJs: [function(next) {
577 _checkAndUpdateJavaScript(self, next);
578 }],
579 ensureCss: ['ensureJs', function(next) {
580 _checkAndUpdateCss(self, next);
581 }],
582 ensureImages: ['ensureCss', function(next) {
583 _checkAndUpdateImages(self, next);
584 }],
585 ensureExtras: ['ensureImages', function(next) {
586 _checkAndUpdateExtras(self, next);
587 }]
588 }, function(err, results) {
589 const result = _.extend({}, results && results.ensureJs, results && results.ensureCss, results && results.ensureImages, results && results.ensureExtras);
590 callback(err, result);
591 });
592
593};
594
595AssetProcessor.prototype.processAssets = function(callback) {
596
597 const self = this;
598
599 async.auto({
600 processJs: [function(next) {
601 self.saveJavaScriptToFile(next);
602 }],
603 processCss: [function(next) {
604 self.saveCssToFile(next);
605 }]
606 }, function(err, results) {
607
608 let result = {};
609
610 if (results) {
611 result = _.extend({},
612 results.processJs,
613 results.processCss,
614 {imagesUrl: ''},
615 {extrasUrl: ''}
616 );
617 }
618
619 callback(err, result);
620 });
621
622};
623
624/**
625 * Helper function to upload files preserving directory structure within a relative root.
626 * Calls back with the relative folder path it uploaded directory structure into.
627 * @param {string[]} files array of full file paths
628 * @param {string} activeRoot full path to the relevant root
629 * @param {string} targetFolder path to upload to
630 * @param {S3} s3 client doing the uploading
631 * @param {function} callback function(err, folderPath)
632 * @return {EventEmitter} emitter for upload events
633 * @private
634 */
635function _uploadRelativeToRoot(files, activeRoot, targetFolder, s3, callback) {
636 const eventEmitter = new EventEmitter();
637 async.eachSeries(files, function(file, eachNext) {
638 const target = targetFolder+'/'+_stripRoot(file, activeRoot);
639 const headers = {
640 'x-amz-acl': 'public-read',
641 'Cache-Control': 'public, max-age=' + SEVEN_DAYS_IN_SECONDS
642 };
643 eventEmitter.emit('uploadStarted', {target: target, source: file});
644 s3.putFile(file, target, headers, function(err, result) {
645 eventEmitter.emit('uploadEnded', {source: file, target: target, url: result});
646 eachNext(err);
647 });
648 }, function(err) {
649 callback(err, s3.urlWithBucket(targetFolder));
650 });
651 return eventEmitter;
652}
653
654
655/**
656 * Helper function that strips a root form the beginning of each file.
657 * This will also convert file to use forward slashes
658 * @param {string} file path of file from which to strip the root
659 * @param {string} root the root path to strip away if file path begins with it
660 * @returns {string} file path with the root stripped from each one
661 * @private
662 */
663function _stripRoot(file, root) {
664 const posixRoot = root && root.replace(/\\/g, '/');
665 file = file.replace(/\\/g, '/');
666 const index = file.indexOf(posixRoot);
667 if (index !== -1) {
668 file = file.substr(posixRoot.length);
669 if (file[0] === '/') {
670 // if we have a root and the file starts with slash, strip that too so path is relative
671 file = file.substr(1);
672 }
673 }
674 return file;
675}
676
677/**
678 * Helper function that uses a config paired with defaults to fall back on to get relevant files
679 * @param {object} config the configuration for the file type to retrieve
680 * @param {string} activeRoot to be used to strip out (or normalize)
681 * @param {bool} normalizedFullPath [optional] if true, paths will be full paths (not relative to root) and expanded out (default: false)
682 * @param {function} callback function(err, files)
683 * @private
684 */
685function _getFiles(config, activeRoot, normalizedFullPath, callback) {
686
687 if (typeof normalizedFullPath === 'function') {
688 callback = normalizedFullPath;
689 normalizedFullPath = false;
690 }
691
692 async.auto({
693 getRelevantFiles: [function(next) {
694
695 let directories = config.directories;
696 let specificFiles = config.files;
697
698 if (!directories && !specificFiles) {
699 directories = activeRoot;
700 }
701 if (directories && !(directories instanceof Array)) {
702 directories = [directories];
703 }
704
705 // need to resolve directory paths relative to root
706 directories = _.map(directories || [], function(directory) {
707 return path.resolve(activeRoot, directory);
708 });
709
710 //need to resolve files relative to root
711 specificFiles = _.map(specificFiles || [], function(specificFile) {
712 return path.resolve(activeRoot, specificFile);
713 });
714
715 const extensions = config.extensions || [];
716 const preference = config.preference || [];
717 const exclusions = config.exclude || [];
718
719 _getRelevantFiles(activeRoot, directories, specificFiles, extensions, preference, exclusions, next);
720 }],
721 normalizeFullPathIfNeeded: ['getRelevantFiles', function(next, results) {
722 let files = results.getRelevantFiles;
723 if (normalizedFullPath) {
724 files = _.map(files, function(file) {
725 if (file[0] !== '/') {
726 return path.normalize(path.join(activeRoot, file));
727 }
728 });
729 }
730 next(null, files);
731 }]
732 }, function(err, results) {
733 callback(err, results && results.normalizeFullPathIfNeeded);
734 });
735}
736
737/**
738 * Helper function that recursively searches a directory for relevant files based on any extension, preference, and exclusions.
739 * @param {string|} root full path that is the root of the directories
740 * @param {string|string[]} directories full path or array of paths to the directory to search; or null if root will be used.
741 * @param {string|string[]} specificFiles full path or array of paths to specific files to include.
742 * @param {string[]} extensions array of acceptable file extensions or null/empty if all are allowed
743 * @param {string[]} preference array of file order that should be enforced or null/empty if no order
744 * @param {string[]} exclusions array of directories and files to exclude
745 * @param {function} callback function(err, files)
746 * @private
747 */
748function _getRelevantFiles(root, directories, specificFiles, extensions, preference, exclusions, callback) {
749
750 async.auto({
751 files: [function(next) {
752 // recurse into all directories to get complete list of files
753 let files = specificFiles || [];
754 async.each(directories, function(directory, eachNext) {
755 dir.files(directory, function(err, dirFiles) {
756 files = files.concat(dirFiles || []);
757 eachNext(err);
758 });
759 }, function(err) {
760 next(err, files);
761 });
762 }],
763 process: ['files', function(next, results) {
764
765 // strip root of file (which will also ensure file path is in posix format)
766 let files = _.map(results.files, function(file) {
767 return _stripRoot(file, root);
768 });
769
770 // filter the files
771 files = files.filter(function(file) {
772 // check against any extension requirements
773 const extensionMatch = _extensionMatch(file, extensions);
774
775 // check against any exclusions
776 const excluded = (exclusions || []).some(function(excludePath) {
777 return file.toLowerCase().indexOf(excludePath.toLowerCase()) === 0;
778 });
779
780 // check if the file is included in "preference"
781 const included = (preference || []).some(function(preference) {
782 return file.toLowerCase() === preference.toLowerCase();
783 });
784
785 let include = false;
786 if (included) {
787 include = true;
788 } else if (extensionMatch) {
789 include = !excluded;
790 }
791
792 // if extension matches and not excluded, keep the file!
793 return include;
794 });
795
796 // specific file orders will be kept if no preference
797 preference = (preference || []).concat(_.map(specificFiles, function(specificFile) {
798 return _stripRoot(specificFile, root);
799 }));
800
801 // sort based on preference
802 files.sort(function(a,b) {
803 const preferenceDiff = _preference(preference, a) - _preference(preference, b);
804 return preferenceDiff !== 0 ? preferenceDiff : a < b ? -1 : a > b ? 1 : 0;
805 });
806
807 // sanity check against preference and specific file configuration
808 preference.forEach(function(preferencePath) {
809 const fullPath = path.resolve(root, preferencePath);
810 // don't want to warn about extensions we are filtering out
811 if (!fs.existsSync(fullPath) && (!path.extname(fullPath) || _extensionMatch(fullPath, extensions))) {
812 console.warn('Warning: '+preferencePath+' was in configuration but cannot be found');
813 }
814 });
815
816 next(null, files);
817 }]
818 }, function(err, results) {
819 callback(err, results && results.process);
820 });
821}
822
823/**
824 * Helper function that ranks files based on preference
825 * @param {string[]} preferences type of files to get (js|css)
826 * @param {string} fileName the relative name of the file being ranked
827 * @returns {number} the rank of the file taking into account order preference
828 * @private
829 */
830function _preference(preferences, fileName) {
831
832 let i;
833 let currPreference;
834
835 fileName = fileName.toLowerCase(); // Lets do case-insensitive file names
836
837 for (i = 0; i < preferences.length; i += 1) {
838 currPreference = preferences[i].toLowerCase();
839 if (path.extname(currPreference)) {
840 //preference is a file, lets see if we have an exact match
841 if (currPreference === fileName) {
842 break;
843 }
844 } else {
845 // preference is a directory, see if its contained in it
846 // we know the directory is contained if we never have to traverse up
847 if (path.relative(currPreference, fileName).indexOf('..') === -1) {
848 break;
849 }
850 }
851 }
852
853 return i;
854}
855
856/**
857 * Computes a hash of the given files
858 * @param {string[]} files array of full paths of the files to hash
859 * @param {function} callback function(err, hash)
860 * @private
861 */
862function _hashFiles(files, callback) {
863
864 const hash = crypto.createHash('md5');
865
866 async.eachSeries(files, function(file, eachNext) {
867 fs.readFile(file, function(err, data) {
868 hash.update(data);
869 eachNext(err);
870 });
871 }, function(err) {
872 callback(err, !err && files.length && hash.digest('hex'));
873 });
874}
875
876function _getJavaScriptPath(files, callback) {
877 _hashAndGeneratePath(files, 'js', '.js', callback);
878}
879
880function _getCssPath(files, callback) {
881 _hashAndGeneratePath(files, 'css', '.css', callback);
882}
883
884function _getImageFolder(imagesRootRelative, callback) {
885 return _firstDirNameFromRootPath(imagesRootRelative, 'img', callback);
886}
887
888function _getExtrasFolder(extrasRootRelative, callback) {
889 return _firstDirNameFromRootPath(extrasRootRelative, 'extra', callback);
890}
891
892function _firstDirNameFromRootPath(rootPath, defaultDirName, callback) {
893 const folder = path.sep+(_firstDirName(rootPath) || defaultDirName);
894 if (callback) {
895 callback(null, folder);
896 }
897 return folder;
898}
899
900function _firstDirName(dirPath) {
901 dirPath = (dirPath || '').replace(/[\\\/]/g, path.sep);
902
903 if (dirPath[0] === path.sep) {
904 dirPath = dirPath.substr(1);
905 }
906
907 return dirPath.split(path.sep)[0] || '';
908}
909
910function _hashAndGeneratePath(files, path, extension, callback) {
911 async.auto({
912 hashFiles: [function(next) {
913 _hashFiles(files, next);
914 }],
915 path: ['hashFiles', function(next, results) {
916 const hash = results.hashFiles;
917 const uri = path+'/'+hash+extension;
918 next(null, uri);
919 }]
920 }, function(err, results) {
921 callback(err, results && results.path);
922 });
923}
924
925function _checkAndUpdateJavaScript(self, callback) {
926
927 async.auto({
928 getJsFiles: [function (next) {
929 self.getJavaScriptFiles(true, next);
930 }],
931 getJsPath: ['getJsFiles', function (next, results) {
932 _getJavaScriptPath(results.getJsFiles, next);
933 }],
934 checkJs: ['getJsPath', function (next, results) {
935 self.s3.fileExists(results.getJsPath, next);
936 }],
937 updateJsIfNeeded: ['checkJs', function (next, results) {
938 const jsChanged = !results.checkJs;
939
940 self.emit('filesChecked', {type: 'js', changed: jsChanged});
941
942 if (jsChanged || self.forceCdnUpdate) {
943 // time to update javascript
944 self.uploadJavaScriptToCdn(function (err, newUrl) {
945 next(err, newUrl);
946 });
947 } else {
948 // no update needed
949 next(null, self.s3.urlWithBucket(results.getJsPath));
950 }
951 }]
952 }, function(err, results) {
953 const result = {
954 jsUrl: results && results.updateJsIfNeeded,
955 jsChanged: results && !results.checkJs
956 };
957 callback(err, result);
958 });
959}
960
961function _checkAndUpdateCss(self, callback) {
962
963 async.auto({
964 getCssFiles: [function (next) {
965 self.getCssFiles(true, next);
966 }],
967 getCssPath: ['getCssFiles', function (next, results) {
968 _getCssPath(results.getCssFiles, next);
969 }],
970 checkCss: ['getCssPath', function (next, results) {
971 self.s3.fileExists(results.getCssPath, next);
972 }],
973 updateCssIfNeeded: ['checkCss', function (next, results) {
974 const cssChanged = !results.checkCss;
975
976 self.emit('filesChecked', {type: 'css', changed: cssChanged});
977
978 if (cssChanged || self.forceCdnUpdate) {
979 // time to update css
980 self.uploadCssToCdn(function (err, newUrl) {
981 next(err, newUrl);
982 });
983 } else {
984 // no update needed
985 next(null, self.s3.urlWithBucket(results.getCssPath));
986 }
987 }]
988 }, function(err, results) {
989 const result = {
990 cssUrl: results && results.updateCssIfNeeded,
991 cssChanged: results && !results.checkCss
992 };
993 callback(err, result);
994 });
995}
996
997function _checkAndUpdateImages(self, callback) {
998 _checkRelativeToRoot(self, 'image', callback);
999}
1000
1001function _checkAndUpdateExtras(self, callback) {
1002 _checkRelativeToRoot(self, 'extra', callback);
1003}
1004
1005function _checkRelativeToRoot(self, type, callback) {
1006
1007 async.auto({
1008 getFiles: [function(next) {
1009 if (type === 'image') {
1010 self.getImageFiles(true, next);
1011 } else {
1012 self.getExtraFiles(true, next);
1013 }
1014 }],
1015 getFolder: [function(next) {
1016 if (type === 'image') {
1017 _getImageFolder(self.config.imagesRootRelative, next);
1018 } else {
1019 _getExtrasFolder(self.config.extrasRootRelative, next);
1020 }
1021 }],
1022 hashAndGeneratePath: ['getFiles', 'getFolder', function(next, results) {
1023 _hashAndGeneratePath(results.getFiles, results.getFolder, '.txt', next);
1024 }],
1025 checkFiles: ['hashAndGeneratePath', function(next, results) {
1026 self.s3.fileExists(results.hashAndGeneratePath, next);
1027 }],
1028 updateIfNeeded: ['checkFiles', function(next, results) {
1029 const filesChanged = !results.checkFiles;
1030 self.emit('filesChecked', {type: type, changed: filesChanged});
1031
1032 if (filesChanged || self.forceCdnUpdate) {
1033 if (type === 'image') {
1034 self.uploadImagesToCdn(next);
1035 } else {
1036 self.uploadExtrasToCdn(next);
1037 }
1038 } else {
1039 next(null, self.s3.urlWithBucket(results.getFolder));
1040 }
1041 }],
1042 uploadIndicatorFileIfNeeded: ['updateIfNeeded', function(next, results) {
1043 const targetPath = results.hashAndGeneratePath;
1044 if (!results.checkFiles) {
1045 self.emit('uploadStarted', {type: type, target: targetPath, source: 'memory'});
1046 self.s3.putBuffer(results.getFiles.join('\n'), targetPath, function(err, result) {
1047 self.emit('uploadEnded', {type: type, target: targetPath, source: 'memory', url: result});
1048 next(err);
1049 });
1050 } else {
1051 next();
1052 }
1053 }]
1054 }, function(err, results) {
1055 const result = {};
1056
1057 // we use the plural of type for results
1058 type = type+'s';
1059
1060 result[type+'Url'] = results && results.updateIfNeeded;
1061 result[type+'Changed'] = !results.checkFiles;
1062
1063 callback(err, result);
1064 });
1065
1066}
1067
1068/**
1069 * Rebases urls found within url() in our CSS to be relative to the stylesheet. This
1070 * allows our assets to be properly linked no matter where they are hosted.
1071 *
1072 * @param {object} self the "this" scope of an AssetProcessor
1073 * @param {string} file the full path to the css file (used to help resolve relative paths)
1074 * @param {string} css the css to process
1075 * @returns {string} the css with url() functions rebased
1076 * @private
1077 */
1078function _rebaseUrls(self, file, css) {
1079
1080 const cssRoot = self.config.stylesheetsRoot;
1081
1082 let normalizedUrl; //url normalized to a file system path
1083 let assetPath; //full path of the asset
1084 let rebasedUrl; //rebased url
1085
1086 // Regular expressions used to test each url found
1087 const urlRegex = /url\((['"]?([^'"\)]+)['"]?)\)/ig;
1088 const externalHttpRegex = /^(http(s)?:)?\/\//i;
1089 const absoluteUrlRegex = /^[\/\\][^\/]/;
1090 const base64Regex = /^data:.+;base64/i;
1091
1092 let match;
1093 let url;
1094 let startIndex;
1095 let endIndex;
1096
1097 file = path.normalize(file);
1098
1099 while ((match = urlRegex.exec(css)) !== null) {
1100 url = match[1];
1101 startIndex = match.index+match[0].indexOf(url); // we want start index of just the actual url
1102 endIndex = startIndex + url.length;
1103
1104 // clean off any quotes from url, as they are not needed
1105 url = url.replace(/^['"]/, '').replace(/['"]$/, '');
1106
1107 // we don't want to rebase any external urls, so first check for that
1108 if (!externalHttpRegex.test(url) && !base64Regex.test(url)) {
1109
1110 // if here, we are referencing our own files. Lets see if its an image or something extra so we know which root to use.
1111 normalizedUrl = path.normalize(url);
1112
1113 // the asset path will either be absolute location from the root, or relative to the file
1114 if (absoluteUrlRegex.test(normalizedUrl)) {
1115 /**
1116 * If the path is abosolute we can leave it as is, since it is already
1117 * a web safe url.
1118 *
1119 * -- Example --
1120 * Asset in CSS: '/images/logo.png'
1121 */
1122 rebasedUrl = normalizedUrl
1123 } else {
1124 /**
1125 * If we have a relative asset in our CSS we are going to resolve it
1126 * to its local path and then make it an absolute web path based on
1127 * the css root path.
1128 *
1129 * -- Example --
1130 * CSS: /app/public/stylesheet/styles.css
1131 * Asset Path in CSS: ../images/logo.png
1132 * Resolved Local Path: /app/public/images/logo.png
1133 * Resolved Web Path: /images/logo.png
1134 */
1135 rebasedUrl = path
1136 .resolve(path.dirname(file), normalizedUrl)
1137 .replace(cssRoot, '');
1138 }
1139
1140 // replace url with rebased one we do so by splicing out match and splicing in url; replacement functions could inadvertently replace repeat urls
1141 css = css.substr(0, startIndex) + rebasedUrl + css.substr(endIndex);
1142
1143 // since we've modified the string, indexes may have change. have regex resume searching at end of replaced url
1144 urlRegex.lastIndex = endIndex;
1145 }
1146 }
1147
1148 return css;
1149}
1150
1151/**
1152 * Imports a file from a github repo to the file system
1153 * @param sourceUrl {string} url to the file to import
1154 * @param destPath {string} path to save the file
1155 * @param accessToken {string} github access token used to request file
1156 * @param callback {function} function(err, importSuccess)
1157 * @private
1158 */
1159function _importFromGit(sourceUrl, destPath, accessToken, callback) {
1160
1161 const apiUrl = _toGitHubApiSource(sourceUrl);
1162 const requestOptions = {
1163 url: apiUrl,
1164 headers: {
1165 Authorization: 'token '+accessToken, // our authentication
1166 Accept: 'application/vnd.github.v3.raw', // we want raw response
1167 'User-Agent': 'NodeBot (+http://videoblocks.com)' // need this or github api will reject us
1168 }
1169 };
1170
1171 const ws = fs.createWriteStream(destPath);
1172
1173 // Create the streams
1174 const req = request(requestOptions);
1175 let statusCode;
1176 let reqErr;
1177
1178 req.on('response', function(response) {
1179 statusCode = response.statusCode;
1180 });
1181
1182 req.on('end', function() {
1183 const success = statusCode === 200;
1184 reqErr = reqErr || (!success ? new Error('Error requesting '+apiUrl+'; status code '+statusCode) : null);
1185 });
1186
1187 req.on('error', function(err) {
1188 reqErr = reqErr || err;
1189 });
1190
1191 ws.on('close', function() {
1192 const success = statusCode === 200;
1193 callback(reqErr, success);
1194 });
1195
1196 req.pipe(ws);
1197}
1198
1199function _applyImportMappings(filePath, mappings, callback) {
1200
1201 async.auto({
1202 readFile: [function(next) {
1203 fs.readFile(filePath, 'utf8', next);
1204 }],
1205 replaceMappings: ['readFile', function(next, results) {
1206 let css = results.readFile;
1207
1208 Object.keys(mappings || {}).forEach(function(importedRoot) {
1209 const search = _escapeRegExp(importedRoot);
1210 const replacement = mappings[importedRoot];
1211 css = css.replace(new RegExp(search, 'g'), replacement);
1212 });
1213
1214 next(null, css);
1215 }],
1216 writeFile: ['replaceMappings', function(next, results) {
1217 const remappedCss = results.replaceMappings;
1218 fs.writeFile(filePath, remappedCss, next);
1219 }]
1220 }, function(err) {
1221 callback(err);
1222 });
1223}
1224
1225/**
1226 * Changes web urls for files on github to be compliant with the github API
1227 * Examples:
1228 * https://github.com/Footage-Firm/StockBlocks/blob/master/assets/common/stylesheets/admin/on_off.css ->
1229 * https://api.github.com/repos/Footage-Firm/StockBlocks/contents/assets/common/stylesheets/admin/on_off.css
1230 *
1231 * @param sourceUrl {string}
1232 * @private
1233 */
1234function _toGitHubApiSource(sourceUrl) {
1235
1236 // web url is of format http://github.com/<org>/<repo>/blob/<branch>/<path>
1237 const gitHubBlobUrlRegex = /^https?:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/[^\/]+\/(.*)$/;
1238 const match = gitHubBlobUrlRegex.exec(sourceUrl);
1239 let org;
1240 let repo;
1241 let path;
1242 if (match) {
1243 // change to api format
1244 org = match[1];
1245 repo = match[2];
1246 path = match[3];
1247 sourceUrl = 'https://api.github.com/repos/'+org+'/'+repo+'/contents/'+path;
1248 }
1249
1250 if (!sourceUrl.match(/^https?:\/\/api\.github\.com\/repos\//)) {
1251 throw new Error('Invalid github import source URL: '+sourceUrl);
1252 }
1253
1254 return sourceUrl;
1255}
1256
1257/**
1258 * Helper function to see if a path points to a file whose extension (case-insensitive) matches one of many possibilities.
1259 * If no extensions provided its considered a wildcard match.
1260 * @param {string} filePath path to the file
1261 * @param {string[]} extensions the possible extensions
1262 * @returns {boolean} whether or not an extension matched
1263 * @private
1264 */
1265function _extensionMatch(filePath, extensions) {
1266 filePath = (filePath || '').toLowerCase();
1267 return !extensions || !extensions.length || extensions.some(function(extension) {
1268 return path.extname(filePath).toLowerCase() === extension.toLowerCase();
1269 });
1270}
1271
1272function _escapeRegExp(string) {
1273 return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
1274}
1275
1276module.exports = AssetProcessor;