MDL-65992 travis: Migrate to Xenial distro and default MySQL service
[moodle.git] / Gruntfile.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15 /* jshint node: true, browser: false */
16 /* eslint-env node */
18 /**
19  * @copyright  2014 Andrew Nicols
20  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
21  */
23 /**
24  * Grunt configuration
25  */
27 module.exports = function(grunt) {
28     var path = require('path'),
29         tasks = {},
30         cwd = process.env.PWD || process.cwd(),
31         async = require('async'),
32         DOMParser = require('xmldom').DOMParser,
33         xpath = require('xpath'),
34         semver = require('semver'),
35         watchman = require('fb-watchman'),
36         watchmanClient = new watchman.Client(),
37         gruntFilePath = process.cwd();
39     // Verify the node version is new enough.
40     var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
41     var actual = semver.valid(process.version);
42     if (!semver.satisfies(actual, expected)) {
43         grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
44     }
46     // Windows users can't run grunt in a subdirectory, so allow them to set
47     // the root by passing --root=path/to/dir.
48     if (grunt.option('root')) {
49         var root = grunt.option('root');
50         if (grunt.file.exists(__dirname, root)) {
51             cwd = path.join(__dirname, root);
52             grunt.log.ok('Setting root to ' + cwd);
53         } else {
54             grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
55         }
56     }
58     var files = null;
59     if (grunt.option('files')) {
60         // Accept a comma separated list of files to process.
61         files = grunt.option('files').split(',');
62     }
64     var inAMD = path.basename(cwd) == 'amd';
66     // Globbing pattern for matching all AMD JS source files.
67     var amdSrc = [inAMD ? cwd + '/src/*.js' : '**/amd/src/*.js'];
69     /**
70      * Function to generate the destination for the uglify task
71      * (e.g. build/file.min.js). This function will be passed to
72      * the rename property of files array when building dynamically:
73      * http://gruntjs.com/configuring-tasks#building-the-files-object-dynamically
74      *
75      * @param {String} destPath the current destination
76      * @param {String} srcPath the  matched src path
77      * @return {String} The rewritten destination path.
78      */
79     var babelRename = function(destPath, srcPath) {
80         destPath = srcPath.replace('src', 'build');
81         destPath = destPath.replace('.js', '.min.js');
82         return destPath;
83     };
85     /**
86      * Find thirdpartylibs.xml and generate an array of paths contained within
87      * them (used to generate ignore files and so on).
88      *
89      * @return {array} The list of thirdparty paths.
90      */
91     var getThirdPartyPathsFromXML = function() {
92         var thirdpartyfiles = grunt.file.expand('*/**/thirdpartylibs.xml');
93         var libs = ['node_modules/', 'vendor/'];
95         thirdpartyfiles.forEach(function(file) {
96           var dirname = path.dirname(file);
98           var doc = new DOMParser().parseFromString(grunt.file.read(file));
99           var nodes = xpath.select("/libraries/library/location/text()", doc);
101           nodes.forEach(function(node) {
102             var lib = path.join(dirname, node.toString());
103             if (grunt.file.isDir(lib)) {
104                 // Ensure trailing slash on dirs.
105                 lib = lib.replace(/\/?$/, '/');
106             }
108             // Look for duplicate paths before adding to array.
109             if (libs.indexOf(lib) === -1) {
110                 libs.push(lib);
111             }
112           });
113         });
114         return libs;
115     };
117     // Project configuration.
118     grunt.initConfig({
119         eslint: {
120             // Even though warnings dont stop the build we don't display warnings by default because
121             // at this moment we've got too many core warnings.
122             options: {quiet: !grunt.option('show-lint-warnings')},
123             amd: {src: files ? files : amdSrc},
124             // Check YUI module source files.
125             yui: {src: files ? files : ['**/yui/src/**/*.js', '!*/**/yui/src/*/meta/*.js']}
126         },
127         babel: {
128             options: {
129                 sourceMaps: true,
130                 comments: false,
131                 plugins: [
132                     'transform-es2015-modules-amd-lazy',
133                     // This plugin modifies the Babel transpiling for "export default"
134                     // so that if it's used then only the exported value is returned
135                     // by the generated AMD module.
136                     //
137                     // It also adds the Moodle plugin name to the AMD module definition
138                     // so that it can be imported as expected in other modules.
139                     path.resolve('babel-plugin-add-module-to-define.js'),
140                     '@babel/plugin-syntax-dynamic-import',
141                     '@babel/plugin-syntax-import-meta',
142                     ['@babel/plugin-proposal-class-properties', {'loose': false}],
143                     '@babel/plugin-proposal-json-strings'
144                 ],
145                 presets: [
146                     ['minify', {
147                         // This minification plugin needs to be disabled because it breaks the
148                         // source map generation and causes invalid source maps to be output.
149                         simplify: false,
150                         builtIns: false
151                     }],
152                     ['@babel/preset-env', {
153                         targets: {
154                             browsers: [
155                                 ">0.25%",
156                                 "last 2 versions",
157                                 "not ie <= 10",
158                                 "not op_mini all",
159                                 "not Opera > 0",
160                                 "not dead"
161                             ]
162                         },
163                         modules: false,
164                         useBuiltIns: false
165                     }]
166                 ]
167             },
168             dist: {
169                 files: [{
170                     expand: true,
171                     src: files ? files : amdSrc,
172                     rename: babelRename
173                 }]
174             }
175         },
176         sass: {
177             dist: {
178                 files: {
179                     "theme/boost/style/moodle.css": "theme/boost/scss/preset/default.scss",
180                     "theme/classic/style/moodle.css": "theme/classic/scss/classicgrunt.scss"
181                 }
182             },
183             options: {
184                 includePaths: ["theme/boost/scss/", "theme/classic/scss/"]
185             }
186         },
187         watch: {
188             options: {
189                 nospawn: true // We need not to spawn so config can be changed dynamically.
190             },
191             amd: {
192                 files: ['**/amd/src/**/*.js'],
193                 tasks: ['amd']
194             },
195             boost: {
196                 files: ['**/theme/boost/scss/**/*.scss'],
197                 tasks: ['scss']
198             },
199             rawcss: {
200                 files: ['**/*.css', '**/theme/**/!(moodle.css|editor.css)'],
201                 tasks: ['rawcss']
202             },
203             yui: {
204                 files: ['**/yui/src/**/*.js'],
205                 tasks: ['yui']
206             },
207             gherkinlint: {
208                 files: ['**/tests/behat/*.feature'],
209                 tasks: ['gherkinlint']
210             }
211         },
212         shifter: {
213             options: {
214                 recursive: true,
215                 paths: files ? files : [cwd]
216             }
217         },
218         gherkinlint: {
219             options: {
220                 files: files ? files : ['**/tests/behat/*.feature'],
221             }
222         },
223         stylelint: {
224             scss: {
225                 options: {syntax: 'scss'},
226                 src: files ? files : ['*/**/*.scss']
227             },
228             css: {
229                 src: files ? files : ['*/**/*.css'],
230                 options: {
231                     configOverrides: {
232                         rules: {
233                             // These rules have to be disabled in .stylelintrc for scss compat.
234                             "at-rule-no-unknown": true,
235                         }
236                     }
237                 }
238             }
239         }
240     });
242     /**
243      * Generate ignore files (utilising thirdpartylibs.xml data)
244      */
245     tasks.ignorefiles = function() {
246       // An array of paths to third party directories.
247       var thirdPartyPaths = getThirdPartyPathsFromXML();
248       // Generate .eslintignore.
249       var eslintIgnores = ['# Generated by "grunt ignorefiles"', '*/**/yui/src/*/meta/', '*/**/build/'].concat(thirdPartyPaths);
250       grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
251       // Generate .stylelintignore.
252       var stylelintIgnores = [
253           '# Generated by "grunt ignorefiles"',
254           '**/yui/build/*',
255           'theme/boost/style/moodle.css',
256           'theme/classic/style/moodle.css',
257       ].concat(thirdPartyPaths);
258       grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
259     };
261     /**
262      * Shifter task. Is configured with a path to a specific file or a directory,
263      * in the case of a specific file it will work out the right module to be built.
264      *
265      * Note that this task runs the invidiaul shifter jobs async (becase it spawns
266      * so be careful to to call done().
267      */
268     tasks.shifter = function() {
269         var done = this.async(),
270             options = grunt.config('shifter.options');
272         // Run the shifter processes one at a time to avoid confusing output.
273         async.eachSeries(options.paths, function(src, filedone) {
274             var args = [];
275             args.push(path.normalize(__dirname + '/node_modules/shifter/bin/shifter'));
277             // Always ignore the node_modules directory.
278             args.push('--excludes', 'node_modules');
280             // Determine the most appropriate options to run with based upon the current location.
281             if (grunt.file.isMatch('**/yui/**/*.js', src)) {
282                 // When passed a JS file, build our containing module (this happen with
283                 // watch).
284                 grunt.log.debug('Shifter passed a specific JS file');
285                 src = path.dirname(path.dirname(src));
286                 options.recursive = false;
287             } else if (grunt.file.isMatch('**/yui/src', src)) {
288                 // When in a src directory --walk all modules.
289                 grunt.log.debug('In a src directory');
290                 args.push('--walk');
291                 options.recursive = false;
292             } else if (grunt.file.isMatch('**/yui/src/*', src)) {
293                 // When in module, only build our module.
294                 grunt.log.debug('In a module directory');
295                 options.recursive = false;
296             } else if (grunt.file.isMatch('**/yui/src/*/js', src)) {
297                 // When in module src, only build our module.
298                 grunt.log.debug('In a source directory');
299                 src = path.dirname(src);
300                 options.recursive = false;
301             }
303             if (grunt.option('watch')) {
304                 grunt.fail.fatal('The --watch option has been removed, please use `grunt watch` instead');
305             }
307             // Add the stderr option if appropriate
308             if (grunt.option('verbose')) {
309                 args.push('--lint-stderr');
310             }
312             if (grunt.option('no-color')) {
313                 args.push('--color=false');
314             }
316             var execShifter = function() {
318                 grunt.log.ok("Running shifter on " + src);
319                 grunt.util.spawn({
320                     cmd: "node",
321                     args: args,
322                     opts: {cwd: src, stdio: 'inherit', env: process.env}
323                 }, function(error, result, code) {
324                     if (code) {
325                         grunt.fail.fatal('Shifter failed with code: ' + code);
326                     } else {
327                         grunt.log.ok('Shifter build complete.');
328                         filedone();
329                     }
330                 });
331             };
333             // Actually run shifter.
334             if (!options.recursive) {
335                 execShifter();
336             } else {
337                 // Check that there are yui modules otherwise shifter ends with exit code 1.
338                 if (grunt.file.expand({cwd: src}, '**/yui/src/**/*.js').length > 0) {
339                     args.push('--recursive');
340                     execShifter();
341                 } else {
342                     grunt.log.ok('No YUI modules to build.');
343                     filedone();
344                 }
345             }
346         }, done);
347     };
349     tasks.gherkinlint = function() {
350         var done = this.async(),
351             options = grunt.config('gherkinlint.options');
353         var args = grunt.file.expand(options.files);
354         args.unshift(path.normalize(__dirname + '/node_modules/.bin/gherkin-lint'));
355         grunt.util.spawn({
356             cmd: 'node',
357             args: args,
358             opts: {stdio: 'inherit', env: process.env}
359         }, function(error, result, code) {
360             // Propagate the exit code.
361             done(code === 0);
362         });
363     };
365     tasks.startup = function() {
366         // Are we in a YUI directory?
367         if (path.basename(path.resolve(cwd, '../../')) == 'yui') {
368             grunt.task.run('yui');
369         // Are we in an AMD directory?
370         } else if (inAMD) {
371             grunt.task.run('amd');
372         } else {
373             // Run them all!.
374             grunt.task.run('css');
375             grunt.task.run('js');
376             grunt.task.run('gherkinlint');
377         }
378     };
380     /**
381      * This is a wrapper task to handle the grunt watch command. It attempts to use
382      * Watchman to monitor for file changes, if it's installed, because it's much faster.
383      *
384      * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
385      * watcher for backwards compatibility.
386      */
387     tasks.watch = function() {
388         var watchTaskDone = this.async();
389         var watchInitialised = false;
390         var watchTaskQueue = {};
391         var processingQueue = false;
393         // Grab the tasks and files that have been queued up and execute them.
394         var processWatchTaskQueue = function() {
395             if (!Object.keys(watchTaskQueue).length || processingQueue) {
396                 // If there is nothing in the queue or we're already processing then wait.
397                 return;
398             }
400             processingQueue = true;
402             // Grab all tasks currently in the queue.
403             var queueToProcess = watchTaskQueue;
404             // Reset the queue.
405             watchTaskQueue = {};
407             async.forEachSeries(
408                 Object.keys(queueToProcess),
409                 function(task, next) {
410                     var files = queueToProcess[task];
411                     var filesOption = '--files=' + files.join(',');
412                     grunt.log.ok('Running task ' + task + ' for files ' + filesOption);
414                     // Spawn the task in a child process so that it doesn't kill this one
415                     // if it failed.
416                     grunt.util.spawn(
417                         {
418                             // Spawn with the grunt bin.
419                             grunt: true,
420                             // Run from current working dir and inherit stdio from process.
421                             opts: {
422                                 cwd: cwd,
423                                 stdio: 'inherit'
424                             },
425                             args: [task, filesOption]
426                         },
427                         function(err, res, code) {
428                             if (code !== 0) {
429                                 // The grunt task failed.
430                                 grunt.log.error(err);
431                             }
433                             // Move on to the next task.
434                             next();
435                         }
436                     );
437                 },
438                 function() {
439                     // No longer processing.
440                     processingQueue = false;
441                     // Once all of the tasks are done then recurse just in case more tasks
442                     // were queued while we were processing.
443                     processWatchTaskQueue();
444                 }
445             );
446         };
448         var watchConfig = grunt.config.get(['watch']);
449         watchConfig = Object.keys(watchConfig).reduce(function(carry, key) {
450             if (key == 'options') {
451                 return carry;
452             }
454             var value = watchConfig[key];
455             var fileGlobs = value.files;
456             var taskNames = value.tasks;
458             taskNames.forEach(function(taskName) {
459                 carry[taskName] = fileGlobs;
460             });
462             return carry;
463         }, {});
465         watchmanClient.on('error', function(error) {
466             // We have to add an error handler here and parse the error string because the
467             // example way from the docs to check if Watchman is installed doesn't actually work!!
468             // See: https://github.com/facebook/watchman/issues/509
469             if (error.message.match('Watchman was not found')) {
470                 // If watchman isn't installed then we should fallback to the other watch task.
471                 grunt.log.ok('It is recommended that you install Watchman for better performance using the "watch" command.');
473                 // Fallback to the old grunt-contrib-watch task.
474                 grunt.renameTask('watch-grunt', 'watch');
475                 grunt.task.run(['watch']);
476                 // This task is finished.
477                 watchTaskDone(0);
478             } else {
479                 grunt.log.error(error);
480                 // Fatal error.
481                 watchTaskDone(1);
482             }
483         });
485         watchmanClient.on('subscription', function(resp) {
486             if (resp.subscription !== 'grunt-watch') {
487                 return;
488             }
490             resp.files.forEach(function(file) {
491                 grunt.log.ok('File changed: ' + file.name);
493                 var fullPath = cwd + '/' + file.name;
494                 Object.keys(watchConfig).forEach(function(task) {
495                     var fileGlobs = watchConfig[task];
496                     var match = fileGlobs.every(function(fileGlob) {
497                         return grunt.file.isMatch(fileGlob, fullPath);
498                     });
499                     if (match) {
500                         // If we are watching a subdirectory then the file.name will be relative
501                         // to that directory. However the grunt tasks  expect the file paths to be
502                         // relative to the Gruntfile.js location so let's normalise them before
503                         // adding them to the queue.
504                         var relativePath = fullPath.replace(gruntFilePath + '/', '');
505                         if (task in watchTaskQueue) {
506                             if (!watchTaskQueue[task].includes(relativePath)) {
507                                 watchTaskQueue[task] = watchTaskQueue[task].concat(relativePath);
508                             }
509                         } else {
510                             watchTaskQueue[task] = [relativePath];
511                         }
512                     }
513                 });
514             });
516             processWatchTaskQueue();
517         });
519         process.on('SIGINT', function() {
520             // Let the user know that they may need to manually stop the Watchman daemon if they
521             // no longer want it running.
522             if (watchInitialised) {
523                 grunt.log.ok('The Watchman daemon may still be running and may need to be stopped manually.');
524             }
526             process.exit();
527         });
529         // Initiate the watch on the current directory.
530         watchmanClient.command(['watch-project', cwd], function(watchError, watchResponse) {
531             if (watchError) {
532                 grunt.log.error('Error initiating watch:', watchError);
533                 watchTaskDone(1);
534                 return;
535             }
537             if ('warning' in watchResponse) {
538                 grunt.log.error('warning: ', watchResponse.warning);
539             }
541             var watch = watchResponse.watch;
542             var relativePath = watchResponse.relative_path;
543             watchInitialised = true;
545             watchmanClient.command(['clock', watch], function(clockError, clockResponse) {
546                 if (clockError) {
547                     grunt.log.error('Failed to query clock:', clockError);
548                     watchTaskDone(1);
549                     return;
550                 }
552                 // Use the matching patterns specified in the watch config.
553                 var matches = Object.keys(watchConfig).map(function(task) {
554                     var fileGlobs = watchConfig[task];
555                     var fileGlobMatches = fileGlobs.map(function(fileGlob) {
556                         return ['match', fileGlob, 'wholename'];
557                     });
559                     return ['allof'].concat(fileGlobMatches);
560                 });
562                 var sub = {
563                     expression: ["anyof"].concat(matches),
564                     // Which fields we're interested in.
565                     fields: ["name", "size", "type"],
566                     // Add our time constraint.
567                     since: clockResponse.clock
568                 };
570                 if (relativePath) {
571                     sub.relative_root = relativePath;
572                 }
574                 watchmanClient.command(['subscribe', watch, 'grunt-watch', sub], function(subscribeError) {
575                     if (subscribeError) {
576                         // Probably an error in the subscription criteria.
577                         grunt.log.error('failed to subscribe: ', subscribeError);
578                         watchTaskDone(1);
579                         return;
580                     }
582                     grunt.log.ok('Listening for changes to files in ' + cwd);
583                 });
584             });
585         });
586     };
588     // On watch, we dynamically modify config to build only affected files. This
589     // method is slightly complicated to deal with multiple changed files at once (copied
590     // from the grunt-contrib-watch readme).
591     var changedFiles = Object.create(null);
592     var onChange = grunt.util._.debounce(function() {
593         var files = Object.keys(changedFiles);
594         grunt.config('eslint.amd.src', files);
595         grunt.config('eslint.yui.src', files);
596         grunt.config('shifter.options.paths', files);
597         grunt.config('gherkinlint.options.files', files);
598         grunt.config('babel.dist.files', [{expand: true, src: files, rename: babelRename}]);
599         changedFiles = Object.create(null);
600     }, 200);
602     grunt.event.on('watch', function(action, filepath) {
603         changedFiles[filepath] = action;
604         onChange();
605     });
607     // Register NPM tasks.
608     grunt.loadNpmTasks('grunt-contrib-uglify');
609     grunt.loadNpmTasks('grunt-contrib-watch');
610     grunt.loadNpmTasks('grunt-sass');
611     grunt.loadNpmTasks('grunt-eslint');
612     grunt.loadNpmTasks('grunt-stylelint');
613     grunt.loadNpmTasks('grunt-babel');
615     // Rename the grunt-contrib-watch "watch" task because we're going to wrap it.
616     grunt.renameTask('watch', 'watch-grunt');
618     // Register JS tasks.
619     grunt.registerTask('shifter', 'Run Shifter against the current directory', tasks.shifter);
620     grunt.registerTask('gherkinlint', 'Run gherkinlint against the current directory', tasks.gherkinlint);
621     grunt.registerTask('ignorefiles', 'Generate ignore files for linters', tasks.ignorefiles);
622     grunt.registerTask('watch', 'Run tasks on file changes', tasks.watch);
623     grunt.registerTask('yui', ['eslint:yui', 'shifter']);
624     grunt.registerTask('amd', ['eslint:amd', 'babel']);
625     grunt.registerTask('js', ['amd', 'yui']);
627     // Register CSS taks.
628     grunt.registerTask('css', ['stylelint:scss', 'sass', 'stylelint:css']);
629     grunt.registerTask('scss', ['stylelint:scss', 'sass']);
630     grunt.registerTask('rawcss', ['stylelint:css']);
632     // Register the startup task.
633     grunt.registerTask('startup', 'Run the correct tasks for the current directory', tasks.startup);
635     // Register the default task.
636     grunt.registerTask('default', ['startup']);
637 };