diff --git a/package.json b/package.json index dff73ec5d5..791c5f36b8 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "coveralls": "istanbul cover _mocha --report lcovonly -- -R dot && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage" }, "dependencies": { - "async": "2.4.0", "ace-builds": "^1.2.6", + "async": "2.4.0", "autoprefixer": "7.0.1", "bcryptjs": "2.4.3", "body-parser": "^1.9.0", @@ -45,6 +45,7 @@ "jquery": "^3.1.0", "json-2-csv": "^2.0.22", "less": "^2.0.0", + "lodash.padstart": "^4.6.1", "logrotate-stream": "^0.2.3", "lru-cache": "4.0.2", "mime": "^1.3.4", diff --git a/public/less/admin/admin.less b/public/less/admin/admin.less index a37682ab83..8f713028ed 100644 --- a/public/less/admin/admin.less +++ b/public/less/admin/admin.less @@ -1,4 +1,4 @@ -@import "../../node_modules/bootstrap/less/bootstrap"; +@import "bootstrap/less/bootstrap"; @import "./paper/variables"; @import "./paper/bootswatch"; @import "./mixins"; diff --git a/src/meta/build.js b/src/meta/build.js index 9ba5ec89bf..e88bbb17e9 100644 --- a/src/meta/build.js +++ b/src/meta/build.js @@ -2,149 +2,212 @@ var async = require('async'); var winston = require('winston'); +var os = require('os'); +var nconf = require('nconf'); +var padstart = require('lodash.padstart'); -var buildStart; +var cacheBuster = require('./cacheBuster'); +var meta; -var valid = ['js', 'clientCSS', 'acpCSS', 'tpl', 'lang', 'sound']; +function step(target, callback) { + var startTime = Date.now(); + winston.info('[build] ' + target + ' build started'); -exports.buildAll = function (callback) { - exports.build(valid.join(','), callback); -}; + return function (err) { + if (err) { + winston.error('[build] ' + target + ' build failed'); + return callback(err); + } -exports.build = function build(targets, callback) { - buildStart = Date.now(); + var time = (Date.now() - startTime) / 1000; - var db = require('../database'); - var meta = require('../meta'); - var plugins = require('../plugins'); + winston.info('[build] ' + target + ' build completed in ' + time + 'sec'); + callback(); + }; +} + +var targetHandlers = { + 'plugin static dirs': function (parallel, callback) { + meta.js.linkStatics(callback); + }, + 'requirejs modules': function (parallel, callback) { + meta.js.buildModules(parallel, callback); + }, + 'client js bundle': function (parallel, callback) { + meta.js.buildBundle('client', parallel, callback); + }, + 'admin js bundle': function (parallel, callback) { + meta.js.buildBundle('admin', parallel, callback); + }, + javascript: [ + 'plugin static dirs', + 'requirejs modules', + 'client js bundle', + 'admin js bundle', + ], + 'client side styles': function (parallel, callback) { + meta.css.buildBundle('client', parallel, callback); + }, + 'admin control panel styles': function (parallel, callback) { + meta.css.buildBundle('admin', parallel, callback); + }, + styles: [ + 'client side styles', + 'admin control panel styles', + ], + templates: function (parallel, callback) { + meta.templates.compile(callback); + }, + languages: function (parallel, callback) { + meta.languages.build(callback); + }, + sounds: function (parallel, callback) { + meta.sounds.build(callback); + }, +}; +var aliases = { + 'plugin static dirs': ['staticdirs'], + 'requirejs modules': ['rjs', 'modules'], + 'client js bundle': ['clientjs', 'clientscript', 'clientscripts'], + 'admin js bundle': ['adminjs', 'adminscript', 'adminscripts'], + javascript: ['js'], + 'client side styles': [ + 'clientcss', 'clientless', 'clientstyles', 'clientstyle', + ], + 'admin control panel styles': [ + 'admincss', 'adminless', 'adminstyles', 'adminstyle', 'acpcss', 'acpless', 'acpstyles', 'acpstyle', + ], + styles: ['css', 'less', 'style'], + templates: ['tpl'], + languages: ['lang', 'i18n'], + sounds: ['sound'], +}; - targets = (targets === true ? valid : targets.split(',').filter(function (target) { - return valid.indexOf(target) !== -1; - })); +aliases = Object.keys(aliases).reduce(function (prev, key) { + var arr = aliases[key]; + arr.forEach(function (alias) { + prev[alias] = key; + }); + prev[key] = key; + return prev; +}, {}); - if (!targets) { - winston.error('[build] No valid build targets found. Aborting.'); - return process.exit(0); - } +function beforeBuild(callback) { + var db = require('../database'); + var plugins = require('../plugins'); + meta = require('../meta'); async.series([ - async.apply(db.init), - async.apply(meta.themes.setupPaths), - async.apply(plugins.prepareForBuild), + db.init, + meta.themes.setupPaths, + plugins.prepareForBuild, ], function (err) { if (err) { winston.error('[build] Encountered error preparing for build: ' + err.message); - return process.exit(1); + return callback(err); } - exports.buildTargets(targets, callback); + callback(); }); -}; +} -exports.buildTargets = function (targets, callback) { - var cacheBuster = require('./cacheBuster'); - var meta = require('../meta'); - var numCpus = require('os').cpus().length; - var parallel = targets.length > 1 && numCpus > 1; +var allTargets = Object.keys(targetHandlers).filter(function (name) { + return typeof targetHandlers[name] === 'function'; +}); +function buildTargets(targets, parallel, callback) { + var all = parallel ? async.each : async.eachSeries; - buildStart = buildStart || Date.now(); + var length = Math.max.apply(Math, targets.map(function (name) { + return name.length; + })); - var step = function (startTime, target, next, err) { - if (err) { - winston.error('Build failed: ' + err.stack); - process.exit(1); - } - winston.info('[build] ' + target + ' => Completed in ' + ((Date.now() - startTime) / 1000) + 's'); - next(); - }; + all(targets, function (target, next) { + targetHandlers[target](parallel, step(padstart(target, length) + ' ', next)); + }, callback); +} - if (parallel) { - winston.verbose('[build] Utilising multiple cores/processes'); - } else { - winston.verbose('[build] Utilising single-core'); +function build(targets, callback) { + if (targets === true) { + targets = allTargets; + } else if (!Array.isArray(targets)) { + targets = targets.split(','); } - async[parallel ? 'parallel' : 'series']([ + targets = targets + // get full target name + .map(function (target) { + target = target.toLowerCase().replace(/-/g, ''); + if (!aliases[target]) { + winston.warn('[build] Unknown target: ' + target); + return false; + } + + return aliases[target]; + }) + // filter nonexistent targets + .filter(Boolean) + // map multitargets to their sets + .reduce(function (prev, target) { + if (Array.isArray(targetHandlers[target])) { + return prev.concat(targetHandlers[target]); + } + + return prev.concat(target); + }, []) + // unique + .filter(function (target, i, arr) { + return arr.indexOf(target) === i; + }); + + if (typeof callback !== 'function') { + callback = function (err) { + if (err) { + winston.error(err); + process.exit(1); + } else { + process.exit(0); + } + }; + } + + if (!targets) { + winston.info('[build] No valid targets supplied. Aborting.'); + callback(); + } + + var startTime; + var totalTime; + async.series([ + beforeBuild, function (next) { - if (targets.indexOf('js') !== -1) { - winston.info('[build] Building javascript'); - var startTime = Date.now(); - async.series([ - meta.js.buildModules, - meta.js.linkStatics, - async.apply(meta.js.minify, 'nodebb.min.js'), - async.apply(meta.js.minify, 'acp.min.js'), - ], step.bind(this, startTime, 'js', next)); + var parallel = os.cpus().length > 1 && !nconf.get('series'); + if (parallel) { + winston.info('[build] Building in parallel mode'); } else { - setImmediate(next); + winston.info('[build] Building in series mode'); } + + startTime = Date.now(); + buildTargets(targets, parallel, next); }, function (next) { - async.eachSeries(targets, function (target, next) { - var startTime; - switch (target) { - case 'js': - setImmediate(next); - break; - case 'clientCSS': - winston.info('[build] Building client-side CSS'); - startTime = Date.now(); - meta.css.minify('client', step.bind(this, startTime, target, next)); - break; - - case 'acpCSS': - winston.info('[build] Building admin control panel CSS'); - startTime = Date.now(); - meta.css.minify('admin', step.bind(this, startTime, target, next)); - break; - - case 'tpl': - winston.info('[build] Building templates'); - startTime = Date.now(); - meta.templates.compile(step.bind(this, startTime, target, next)); - break; - - case 'lang': - winston.info('[build] Building language files'); - startTime = Date.now(); - meta.languages.build(step.bind(this, startTime, target, next)); - break; - - case 'sound': - winston.info('[build] Linking sound files'); - startTime = Date.now(); - meta.sounds.build(step.bind(this, startTime, target, next)); - break; - - default: - winston.warn('[build] Unknown build target: \'' + target + '\''); - setImmediate(next); - break; - } - }, next); + totalTime = (Date.now() - startTime) / 1000; + cacheBuster.write(next); }, ], function (err) { if (err) { winston.error('[build] Encountered error during build step: ' + err.message); - return process.exit(1); + return callback(err); } - cacheBuster.write(function (err) { - if (err) { - winston.error('[build] Failed to write `cache-buster.conf`: ' + err.message); - return process.exit(1); - } - - var time = (Date.now() - buildStart) / 1000; + winston.info('[build] Asset compilation successful. Completed in ' + totalTime + 'sec.'); + callback(); + }); +} - winston.info('[build] Asset compilation successful. Completed in ' + time + 's.'); +exports.build = build; - if (typeof callback === 'function') { - callback(); - } else { - process.exit(0); - } - }); - }); +exports.buildAll = function (callback) { + build(allTargets, callback); }; diff --git a/src/meta/css.js b/src/meta/css.js index 88ad3348e8..127191437c 100644 --- a/src/meta/css.js +++ b/src/meta/css.js @@ -4,15 +4,12 @@ var winston = require('winston'); var nconf = require('nconf'); var fs = require('fs'); var path = require('path'); -var less = require('less'); var async = require('async'); -var autoprefixer = require('autoprefixer'); -var postcss = require('postcss'); -var clean = require('postcss-clean'); var plugins = require('../plugins'); var db = require('../database'); var file = require('../file'); +var minifier = require('./minifier'); module.exports = function (Meta) { Meta.css = {}; @@ -49,50 +46,19 @@ module.exports = function (Meta) { }, }; - Meta.css.minify = function (target, callback) { - callback = callback || function () {}; - - winston.verbose('[meta/css] Minifying LESS/CSS'); - db.getObjectFields('config', ['theme:type', 'theme:id'], function (err, themeData) { - if (err) { - return callback(err); - } - - var themeId = (themeData['theme:id'] || 'nodebb-theme-persona'); - var baseThemePath = path.join(nconf.get('themes_path'), (themeData['theme:type'] && themeData['theme:type'] === 'local' ? themeId : 'nodebb-theme-vanilla')); - var paths = [ - baseThemePath, - path.join(__dirname, '../../node_modules'), - path.join(__dirname, '../../public/vendor/fontawesome/less'), - ]; - var source = ''; - - var lessFiles = filterMissingFiles(plugins.lessFiles); - var cssFiles = filterMissingFiles(plugins.cssFiles); - - async.waterfall([ - function (next) { - getStyleSource(cssFiles, '\n@import (inline) ".', '.css', next); - }, - function (src, next) { - source += src; - getStyleSource(lessFiles, '\n@import ".', '.less', next); - }, - function (src, next) { - source += src; - next(); - }, - ], function (err) { - if (err) { - return callback(err); + function filterMissingFiles(filepaths, callback) { + async.filter(filepaths, function (filepath, next) { + file.exists(path.join(__dirname, '../../node_modules', filepath), function (err, exists) { + if (!exists) { + winston.warn('[meta/css] File not found! ' + filepath); } - minify(buildImports[target](source), paths, target, callback); + next(err, exists); }); - }); - }; + }, callback); + } - function getStyleSource(files, prefix, extension, callback) { + function getImports(files, prefix, extension, callback) { var pluginDirectories = []; var source = ''; @@ -121,55 +87,82 @@ module.exports = function (Meta) { }); } - Meta.css.commitToFile = function (target, source, callback) { - var filename = (target === 'client' ? 'stylesheet' : 'admin') + '.css'; + function getBundleMetadata(target, callback) { + var paths = [ + path.join(__dirname, '../../node_modules'), + path.join(__dirname, '../../public/vendor/fontawesome/less'), + ]; - fs.writeFile(path.join(__dirname, '../../build/public/' + filename), source, function (err) { - if (!err) { - winston.verbose('[meta/css] ' + target + ' CSS committed to disk.'); - } else { - winston.error('[meta/css] ' + err.message); - process.exit(1); - } + async.waterfall([ + function (next) { + if (target !== 'client') { + return next(null, null); + } - callback(); - }); - }; + db.getObjectFields('config', ['theme:type', 'theme:id'], next); + }, + function (themeData, next) { + if (target === 'client') { + var themeId = (themeData['theme:id'] || 'nodebb-theme-persona'); + var baseThemePath = path.join(nconf.get('themes_path'), (themeData['theme:type'] && themeData['theme:type'] === 'local' ? themeId : 'nodebb-theme-vanilla')); + paths.unshift(baseThemePath); + } - function minify(source, paths, target, callback) { - callback = callback || function () {}; - less.render(source, { - paths: paths, - }, function (err, lessOutput) { + async.parallel({ + less: function (cb) { + async.waterfall([ + function (next) { + filterMissingFiles(plugins.lessFiles, next); + }, + function (lessFiles, next) { + getImports(lessFiles, '\n@import ".', '.less', next); + }, + ], cb); + }, + css: function (cb) { + async.waterfall([ + function (next) { + filterMissingFiles(plugins.cssFiles, next); + }, + function (cssFiles, next) { + getImports(cssFiles, '\n@import (inline) ".', '.css', next); + }, + ], cb); + }, + }, next); + }, + function (result, next) { + var cssImports = result.css; + var lessImports = result.less; + + var imports = cssImports + '\n' + lessImports; + imports = buildImports[target](imports); + + next(null, imports); + }, + ], function (err, imports) { if (err) { - winston.error('[meta/css] Could not minify LESS/CSS: ' + err.message); return callback(err); } - postcss(global.env === 'development' ? [autoprefixer] : [ - autoprefixer, - clean({ - processImportFrom: ['local'], - }), - ]).process(lessOutput.css).then(function (result) { - result.warnings().forEach(function (warn) { - winston.verbose(warn.toString()); - }); - - return Meta.css.commitToFile(target, result.css, function () { - callback(null, result.css); - }); - }); + callback(null, { paths: paths, imports: imports }); }); } - function filterMissingFiles(files) { - return files.filter(function (filePath) { - var exists = file.existsSync(path.join(__dirname, '../../node_modules', filePath)); - if (!exists) { - winston.warn('[meta/css] File not found! ' + filePath); - } - return exists; - }); - } + Meta.css.buildBundle = function (target, fork, callback) { + async.waterfall([ + function (next) { + getBundleMetadata(target, next); + }, + function (data, next) { + var minify = global.env !== 'development'; + minifier.css.bundle(data.imports, data.paths, minify, fork, next); + }, + function (bundle, next) { + var filename = (target === 'client' ? 'stylesheet' : 'admin') + '.css'; + + fs.writeFile(path.join(__dirname, '../../build/public', filename), bundle.code, next); + }, + ], callback); + }; }; diff --git a/src/meta/js.js b/src/meta/js.js index f654d45644..e7b22939fc 100644 --- a/src/meta/js.js +++ b/src/meta/js.js @@ -1,136 +1,128 @@ 'use strict'; -var winston = require('winston'); -var fork = require('child_process').fork; var path = require('path'); var async = require('async'); var fs = require('fs'); var mkdirp = require('mkdirp'); var rimraf = require('rimraf'); -var uglifyjs = require('uglify-js'); var file = require('../file'); var plugins = require('../plugins'); - -var minifierPath = path.join(__dirname, 'minifier.js'); +var minifier = require('./minifier'); module.exports = function (Meta) { - Meta.js = { - target: {}, - scripts: { - base: [ - 'node_modules/jquery/dist/jquery.js', - 'node_modules/socket.io-client/dist/socket.io.js', - 'public/vendor/jquery/timeago/jquery.timeago.js', - 'public/vendor/jquery/js/jquery.form.min.js', - 'public/vendor/visibility/visibility.min.js', - 'node_modules/bootstrap/dist/js/bootstrap.js', - 'public/vendor/jquery/bootstrap-tagsinput/bootstrap-tagsinput.min.js', - 'public/vendor/jquery/textcomplete/jquery.textcomplete.js', - 'public/vendor/requirejs/require.js', - 'public/src/require-config.js', - 'public/vendor/bootbox/bootbox.js', - 'public/vendor/bootbox/wrapper.js', - 'public/vendor/tinycon/tinycon.js', - 'public/vendor/xregexp/xregexp.js', - 'public/vendor/xregexp/unicode/unicode-base.js', - 'node_modules/templates.js/lib/templates.js', - 'public/src/utils.js', - 'public/src/sockets.js', - 'public/src/app.js', - 'public/src/ajaxify.js', - 'public/src/overrides.js', - 'public/src/widgets.js', - 'node_modules/promise-polyfill/promise.js', - ], - - // files listed below are only available client-side, or are bundled in to reduce # of network requests on cold load - rjs: [ - 'public/src/client/footer.js', - 'public/src/client/chats.js', - 'public/src/client/infinitescroll.js', - 'public/src/client/pagination.js', - 'public/src/client/recent.js', - 'public/src/client/unread.js', - 'public/src/client/topic.js', - 'public/src/client/topic/events.js', - 'public/src/client/topic/fork.js', - 'public/src/client/topic/move.js', - 'public/src/client/topic/posts.js', - 'public/src/client/topic/images.js', - 'public/src/client/topic/postTools.js', - 'public/src/client/topic/threadTools.js', - 'public/src/client/categories.js', - 'public/src/client/category.js', - 'public/src/client/category/tools.js', - - 'public/src/modules/translator.js', - 'public/src/modules/notifications.js', - 'public/src/modules/chat.js', - 'public/src/modules/components.js', - 'public/src/modules/sort.js', - 'public/src/modules/navigator.js', - 'public/src/modules/topicSelect.js', - 'public/src/modules/share.js', - 'public/src/modules/search.js', - 'public/src/modules/alerts.js', - 'public/src/modules/taskbar.js', - 'public/src/modules/helpers.js', - 'public/src/modules/string.js', - 'public/src/modules/flags.js', - 'public/src/modules/storage.js', - ], - - // modules listed below are built (/src/modules) so they can be defined anonymously - modules: { - 'Chart.js': 'node_modules/chart.js/dist/Chart.min.js', - 'mousetrap.js': 'node_modules/mousetrap/mousetrap.min.js', - 'cropper.js': 'node_modules/cropperjs/dist/cropper.min.js', - 'jqueryui.js': 'public/vendor/jquery/js/jquery-ui.js', - 'zxcvbn.js': 'node_modules/zxcvbn/dist/zxcvbn.js', - ace: 'node_modules/ace-builds/src-min', - }, + Meta.js = {}; + + Meta.js.scripts = { + base: [ + 'node_modules/jquery/dist/jquery.js', + 'node_modules/socket.io-client/dist/socket.io.js', + 'public/vendor/jquery/timeago/jquery.timeago.js', + 'public/vendor/jquery/js/jquery.form.min.js', + 'public/vendor/visibility/visibility.min.js', + 'node_modules/bootstrap/dist/js/bootstrap.js', + 'public/vendor/jquery/bootstrap-tagsinput/bootstrap-tagsinput.min.js', + 'public/vendor/jquery/textcomplete/jquery.textcomplete.js', + 'public/vendor/requirejs/require.js', + 'public/src/require-config.js', + 'public/vendor/bootbox/bootbox.js', + 'public/vendor/bootbox/wrapper.js', + 'public/vendor/tinycon/tinycon.js', + 'public/vendor/xregexp/xregexp.js', + 'public/vendor/xregexp/unicode/unicode-base.js', + 'node_modules/templates.js/lib/templates.js', + 'public/src/utils.js', + 'public/src/sockets.js', + 'public/src/app.js', + 'public/src/ajaxify.js', + 'public/src/overrides.js', + 'public/src/widgets.js', + 'node_modules/promise-polyfill/promise.js', + ], + + // files listed below are only available client-side, or are bundled in to reduce # of network requests on cold load + rjs: [ + 'public/src/client/footer.js', + 'public/src/client/chats.js', + 'public/src/client/infinitescroll.js', + 'public/src/client/pagination.js', + 'public/src/client/recent.js', + 'public/src/client/unread.js', + 'public/src/client/topic.js', + 'public/src/client/topic/events.js', + 'public/src/client/topic/fork.js', + 'public/src/client/topic/move.js', + 'public/src/client/topic/posts.js', + 'public/src/client/topic/images.js', + 'public/src/client/topic/postTools.js', + 'public/src/client/topic/threadTools.js', + 'public/src/client/categories.js', + 'public/src/client/category.js', + 'public/src/client/category/tools.js', + + 'public/src/modules/translator.js', + 'public/src/modules/notifications.js', + 'public/src/modules/chat.js', + 'public/src/modules/components.js', + 'public/src/modules/sort.js', + 'public/src/modules/navigator.js', + 'public/src/modules/topicSelect.js', + 'public/src/modules/share.js', + 'public/src/modules/search.js', + 'public/src/modules/alerts.js', + 'public/src/modules/taskbar.js', + 'public/src/modules/helpers.js', + 'public/src/modules/string.js', + 'public/src/modules/flags.js', + 'public/src/modules/storage.js', + ], + + // modules listed below are built (/src/modules) so they can be defined anonymously + modules: { + 'Chart.js': 'node_modules/chart.js/dist/Chart.min.js', + 'mousetrap.js': 'node_modules/mousetrap/mousetrap.min.js', + 'cropper.js': 'node_modules/cropperjs/dist/cropper.min.js', + 'jqueryui.js': 'public/vendor/jquery/js/jquery-ui.js', + 'zxcvbn.js': 'node_modules/zxcvbn/dist/zxcvbn.js', + ace: 'node_modules/ace-builds/src-min', }, }; - function minifyModules(modules, callback) { + function minifyModules(modules, fork, callback) { + // for it to never fork + // otherwise it spawns way too many processes + // maybe eventually we can pool modules + // and pass the pools to the minifer + // to reduce the total number of threads + fork = false; + async.eachLimit(modules, 500, function (mod, next) { var srcPath = mod.srcPath; var destPath = mod.destPath; - var minified; - async.parallel([ - function (cb) { + async.parallel({ + dirped: function (cb) { mkdirp(path.dirname(destPath), cb); }, - function (cb) { + minified: function (cb) { fs.readFile(srcPath, function (err, buffer) { if (err) { return cb(err); } if (srcPath.endsWith('.min.js') || path.dirname(srcPath).endsWith('min')) { - minified = { code: buffer.toString() }; - return cb(); - } - - try { - minified = uglifyjs.minify(buffer.toString(), { - fromString: true, - compress: false, - }); - } catch (e) { - return cb(e); + return cb(null, { code: buffer.toString() }); } - cb(); + minifier.js.minify(buffer.toString(), fork, cb); }); }, - ], function (err) { + }, function (err, results) { if (err) { return next(err); } + var minified = results.minified; fs.writeFile(destPath, minified.code, next); }); }, callback); @@ -233,7 +225,7 @@ module.exports = function (Meta) { }); } - Meta.js.buildModules = function (callback) { + Meta.js.buildModules = function (fork, callback) { async.waterfall([ clearModules, function (next) { @@ -244,7 +236,7 @@ module.exports = function (Meta) { getModuleList(next); }, function (modules, next) { - minifyModules(modules, next); + minifyModules(modules, fork, next); }, ], callback); }; @@ -269,52 +261,13 @@ module.exports = function (Meta) { }); }; - Meta.js.minify = function (target, callback) { - winston.verbose('[meta/js] Minifying ' + target); - - var forkProcessParams = setupDebugging(); - var minifier = fork(minifierPath, [], forkProcessParams); - Meta.js.minifierProc = minifier; - - Meta.js.target[target] = {}; - - Meta.js.prepare(target, function (err) { - if (err) { - return callback(err); - } - minifier.send({ - action: 'js', - minify: global.env !== 'development', - scripts: Meta.js.target[target].scripts, - }); - }); - - minifier.on('message', function (message) { - switch (message.type) { - case 'end': - Meta.js.target[target].cache = message.minified; - Meta.js.target[target].map = message.sourceMap; - winston.verbose('[meta/js] ' + target + ' minification complete'); - minifier.kill(); - - Meta.js.commitToFile(target, callback); - break; - case 'error': - winston.error('[meta/js] Could not compile ' + target + ': ' + message.message); - minifier.kill(); - - callback(new Error(message.message)); - break; - } - }); - }; - - Meta.js.prepare = function (target, callback) { - var pluginsScripts = []; - + function getBundleScriptList(target, callback) { var pluginDirectories = []; - pluginsScripts = plugins[target === 'nodebb.min.js' ? 'clientScripts' : 'acpScripts'].filter(function (path) { + if (target === 'admin') { + target = 'acp'; + } + var pluginScripts = plugins[target + 'Scripts'].filter(function (path) { if (path.endsWith('.js')) { return true; } @@ -325,8 +278,12 @@ module.exports = function (Meta) { async.each(pluginDirectories, function (directory, next) { file.walk(directory, function (err, scripts) { - pluginsScripts = pluginsScripts.concat(scripts); - next(err); + if (err) { + return next(err); + } + + pluginScripts = pluginScripts.concat(scripts); + next(); }); }, function (err) { if (err) { @@ -335,52 +292,43 @@ module.exports = function (Meta) { var basePath = path.resolve(__dirname, '../..'); - Meta.js.target[target].scripts = Meta.js.scripts.base.concat(pluginsScripts); + var scripts = Meta.js.scripts.base.concat(pluginScripts); - if (target === 'nodebb.min.js') { - Meta.js.target[target].scripts = Meta.js.target[target].scripts.concat(Meta.js.scripts.rjs); + if (target === 'client' && global.env !== 'development') { + scripts = scripts.concat(Meta.js.scripts.rjs); } - Meta.js.target[target].scripts = Meta.js.target[target].scripts.map(function (script) { + scripts = scripts.map(function (script) { return path.resolve(basePath, script).replace(/\\/g, '/'); }); - callback(); + callback(null, scripts); }); - }; + } - Meta.js.killMinifier = function () { - if (Meta.js.minifierProc) { - Meta.js.minifierProc.kill('SIGTERM'); - } - }; + Meta.js.buildBundle = function (target, fork, callback) { + var fileNames = { + client: 'nodebb.min.js', + admin: 'acp.min.js', + }; - Meta.js.commitToFile = function (target, callback) { - fs.writeFile(path.join(__dirname, '../../build/public', target), Meta.js.target[target].cache, function (err) { - callback(err); - }); - }; + async.waterfall([ + function (next) { + getBundleScriptList(target, next); + }, + function (files, next) { + var minify = global.env !== 'development'; - function setupDebugging() { - /** - * Check if the parent process is running with the debug option --debug (or --debug-brk) - */ - var forkProcessParams = {}; - if (global.v8debug || parseInt(process.execArgv.indexOf('--debug'), 10) !== -1) { - /** - * use the line below if you want to debug minifier.js script too (or even --debug-brk option, but - * you'll have to setup your debugger and connect to the forked process) - */ - // forkProcessParams = {execArgv: ['--debug=' + (global.process.debugPort + 1), '--nolazy']}; - - /** - * otherwise, just clean up --debug/--debug-brk options which are set up by default from the parent one - */ - forkProcessParams = { - execArgv: [], - }; - } + minifier.js.bundle(files, minify, fork, next); + }, + function (bundle, next) { + var filePath = path.join(__dirname, '../../build/public', fileNames[target]); + fs.writeFile(filePath, bundle.code, next); + }, + ], callback); + }; - return forkProcessParams; - } + Meta.js.killMinifier = function () { + minifier.killAll(); + }; }; diff --git a/src/meta/minifier.js b/src/meta/minifier.js index 43761c9d33..b1f2888b16 100644 --- a/src/meta/minifier.js +++ b/src/meta/minifier.js @@ -3,86 +3,255 @@ var uglifyjs = require('uglify-js'); var async = require('async'); var fs = require('fs'); +var childProcess = require('child_process'); +var os = require('os'); +var less = require('less'); +var postcss = require('postcss'); +var autoprefixer = require('autoprefixer'); +var clean = require('postcss-clean'); + var file = require('../file'); -var Minifier = { - js: {}, +var Minifier = module.exports; + +function setupDebugging() { + /** + * Check if the parent process is running with the debug option --debug (or --debug-brk) + */ + var forkProcessParams = {}; + if (global.v8debug || parseInt(process.execArgv.indexOf('--debug'), 10) !== -1) { + /** + * use the line below if you want to debug minifier.js script too (or even --debug-brk option, but + * you'll have to setup your debugger and connect to the forked process) + */ + // forkProcessParams = { execArgv: ['--debug=' + (global.process.debugPort + 1), '--nolazy'] }; + + /** + * otherwise, just clean up --debug/--debug-brk options which are set up by default from the parent one + */ + forkProcessParams = { + execArgv: [], + }; + } + + return forkProcessParams; +} + +var children = []; + +Minifier.killAll = function () { + children.forEach(function (child) { + child.kill('SIGTERM'); + }); + + children = []; }; -/* Javascript */ -Minifier.js.minify = function (scripts, minify, callback) { - scripts = scripts.filter(function (file) { - return file && file.endsWith('.js'); +function removeChild(proc) { + children = children.filter(function (child) { + return child !== proc; }); +} - async.filter(scripts, function (script, next) { - file.exists(script, function (err, exists) { - if (err) { - return next(err); - } +function forkAction(action, callback) { + var forkProcessParams = setupDebugging(); + var proc = childProcess.fork(__filename, [], Object.assign({}, forkProcessParams, { + cwd: __dirname, + env: { + minifier_child: true, + }, + })); - if (!exists) { - console.warn('[minifier] file not found, ' + script); - } - next(null, exists); - }); - }, function (err, scripts) { - if (err) { - return callback(err); + children.push(proc); + + proc.on('message', function (message) { + if (message.type === 'error') { + proc.kill(); + return callback(new Error(message.message)); } - if (minify) { - minifyScripts(scripts, callback); - } else { - concatenateScripts(scripts, callback); + if (message.type === 'end') { + proc.kill(); + callback(null, message.result); } }); -}; + proc.on('error', function (err) { + proc.kill(); + removeChild(proc); + callback(err); + }); -process.on('message', function (payload) { - switch (payload.action) { - case 'js': - Minifier.js.minify(payload.scripts, payload.minify, function (minified/* , sourceMap*/) { - process.send({ - type: 'end', - // sourceMap: sourceMap, - minified: minified, + proc.send({ + type: 'action', + action: action, + }); + + proc.on('close', function () { + removeChild(proc); + }); +} + +var actions = {}; + +if (process.env.minifier_child) { + process.on('message', function (message) { + if (message.type === 'action') { + var action = message.action; + if (typeof actions[action.act] !== 'function') { + process.send({ + type: 'error', + message: 'Unknown action', + }); + return; + } + + actions[action.act](action, function (err, result) { + if (err) { + process.send({ + type: 'error', + message: err.message, + }); + return; + } + + process.send({ + type: 'end', + result: result, + }); }); - }); - break; + } + }); +} + +function executeAction(action, fork, callback) { + if (fork) { + forkAction(action, callback); + } else { + if (typeof actions[action.act] !== 'function') { + return callback(Error('Unknown action')); + } + actions[action.act](action, callback); } -}); - -function minifyScripts(scripts, callback) { - // The portions of code involving the source map are commented out as they're broken in UglifyJS2 - // Follow along here: https://github.com/mishoo/UglifyJS2/issues/700 - try { - var minified = uglifyjs.minify(scripts, { - // outSourceMap: "nodebb.min.js.map", - compress: false, - }); +} + +function concat(data, callback) { + if (data.files && data.files.length) { + async.mapLimit(data.files, 1000, fs.readFile, function (err, files) { + if (err) { + return callback(err); + } - callback(minified.code/* , minified.map*/); - } catch (err) { - process.send({ - type: 'error', - message: err.message, + var output = files.join(os.EOL + ';'); + callback(null, { code: output }); }); + + return; } + + callback(); } +actions.concat = concat; -function concatenateScripts(scripts, callback) { - async.map(scripts, fs.readFile, function (err, scripts) { - if (err) { - process.send({ - type: 'error', - message: err.message, +function minifyJS(data, callback) { + var minified; + + if (data.fromSource) { + var sources = data.source; + var multiple = Array.isArray(sources); + if (!multiple) { + sources = [sources]; + } + + try { + minified = sources.map(function (source) { + return uglifyjs.minify(source, { + // outSourceMap: data.filename + '.map', + compress: data.compress, + fromString: true, + output: { + // suppress uglify line length warnings + max_line_len: 400000, + }, + }); }); - return; + } catch (e) { + return callback(e); } - scripts = scripts.join(require('os').EOL + ';'); + return callback(null, multiple ? minified : minified[0]); + } + + if (data.files && data.files.length) { + async.filter(data.files, file.exists, function (err, scripts) { + if (err) { + return callback(err); + } + + try { + minified = uglifyjs.minify(scripts, { + // outSourceMap: data.filename + '.map', + compress: data.compress, + fromString: false, + }); + } catch (e) { + return callback(e); + } + + callback(null, minified); + }); - callback(scripts); + return; + } + + callback(); +} +actions.minifyJS = minifyJS; + +Minifier.js = {}; +Minifier.js.bundle = function (scripts, minify, fork, callback) { + executeAction({ + act: minify ? 'minifyJS' : 'concat', + files: scripts, + compress: false, + }, fork, callback); +}; + +Minifier.js.minify = function (source, fork, callback) { + executeAction({ + act: 'minifyJS', + fromSource: true, + source: source, + }, fork, callback); +}; + +function buildCSS(data, callback) { + less.render(data.source, { + paths: data.paths, + }, function (err, lessOutput) { + if (err) { + return callback(err); + } + + postcss(data.minify ? [ + autoprefixer, + clean({ + processImportFrom: ['local'], + }), + ] : [autoprefixer]).process(lessOutput.css).then(function (result) { + callback(null, { code: result.css }); + }, function (err) { + callback(err); + }); }); } +actions.buildCSS = buildCSS; + +Minifier.css = {}; +Minifier.css.bundle = function (source, paths, minify, fork, callback) { + executeAction({ + act: 'buildCSS', + source: source, + paths: paths, + minify: minify, + }, fork, callback); +};