diff --git a/.eslintignore b/.eslintignore index 0d0a6eab582..a6d54130d43 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ # Generated by "grunt ignorefiles" +!/.grunt */**/yui/src/*/meta/ */**/build/ node_modules/ diff --git a/.eslintrc b/.eslintrc index e44591a3b8d..0dce8d8cdeb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -204,7 +204,7 @@ } }, { - files: ["**/amd/src/*.js", "**/amd/src/**/*.js", "Gruntfile*.js", "babel-plugin-add-module-to-define.js"], + files: ["**/amd/src/*.js", "**/amd/src/**/*.js", "Gruntfile.js", ".grunt/*.js", ".grunt/tasks/*.js"], // We support es6 now. Woot! env: { es6: true diff --git a/babel-plugin-add-module-to-define.js b/.grunt/babel-plugin-add-module-to-define.js similarity index 98% rename from babel-plugin-add-module-to-define.js rename to .grunt/babel-plugin-add-module-to-define.js index dfe68c691e1..2e76d31c318 100644 --- a/babel-plugin-add-module-to-define.js +++ b/.grunt/babel-plugin-add-module-to-define.js @@ -39,7 +39,7 @@ module.exports = ({template, types}) => { const fs = require('fs'); const path = require('path'); const cwd = process.cwd(); - const ComponentList = require(path.resolve('GruntfileComponents.js')); + const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js')); /** * Search the list of components that match the given file name diff --git a/GruntfileComponents.js b/.grunt/components.js similarity index 81% rename from GruntfileComponents.js rename to .grunt/components.js index 74bd9de01e9..848632db6dd 100644 --- a/GruntfileComponents.js +++ b/.grunt/components.js @@ -92,6 +92,16 @@ const fetchComponentData = () => { return componentData; }; +/** + * Get the list of component paths. + * + * @param {string} relativeTo + * @returns {array} + */ +const getComponentPaths = (relativeTo = '') => fetchComponentData().pathList.map(componentPath => { + return componentPath.replace(relativeTo, ''); +}); + /** * Get the list of paths to build AMD sources. * @@ -140,6 +150,49 @@ const getThirdPartyLibsList = relativeTo => { .sort(); }; +/** + * Get the list of thirdparty library paths. + * + * @returns {array} + */ +const getThirdPartyPaths = () => { + const DOMParser = require('xmldom').DOMParser; + const fs = require('fs'); + const path = require('path'); + const xpath = require('xpath'); + + const thirdpartyfiles = getThirdPartyLibsList(fs.realpathSync('./')); + const libs = ['node_modules/', 'vendor/']; + + const addLibToList = lib => { + if (!lib.match('\\*') && fs.statSync(lib).isDirectory()) { + // Ensure trailing slash on dirs. + lib = lib.replace(/\/?$/, '/'); + } + + // Look for duplicate paths before adding to array. + if (libs.indexOf(lib) === -1) { + libs.push(lib); + } + }; + + thirdpartyfiles.forEach(function(file) { + const dirname = path.dirname(file); + + const xmlContent = fs.readFileSync(file, 'utf8'); + const doc = new DOMParser().parseFromString(xmlContent); + const nodes = xpath.select("/libraries/library/location/text()", doc); + + nodes.forEach(function(node) { + let lib = path.posix.join(dirname, node.toString()); + addLibToList(lib); + }); + }); + + return libs; + +}; + /** * Find the name of the component matching the specified path. * @@ -182,7 +235,9 @@ const getOwningComponentDirectory = checkPath => { module.exports = { getAmdSrcGlobList, getComponentFromPath, + getComponentPaths, getOwningComponentDirectory, getYuiSrcGlobList, getThirdPartyLibsList, + getThirdPartyPaths, }; diff --git a/.grunt/tasks/eslint.js b/.grunt/tasks/eslint.js new file mode 100644 index 00000000000..b7408ac88e0 --- /dev/null +++ b/.grunt/tasks/eslint.js @@ -0,0 +1,64 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +module.exports = grunt => { + const files = grunt.moodleEnv.files; + + // Project configuration. + grunt.config.merge({ + eslint: { + // Even though warnings dont stop the build we don't display warnings by default because + // at this moment we've got too many core warnings. + // To display warnings call: grunt eslint --show-lint-warnings + // To fail on warnings call: grunt eslint --max-lint-warnings=0 + // Also --max-lint-warnings=-1 can be used to display warnings but not fail. + options: { + quiet: (!grunt.option('show-lint-warnings')) && (typeof grunt.option('max-lint-warnings') === 'undefined'), + maxWarnings: ((typeof grunt.option('max-lint-warnings') !== 'undefined') ? grunt.option('max-lint-warnings') : -1) + }, + + // Check AMD src files. + amd: {src: files ? files : grunt.moodleEnv.amdSrc}, + + // Check YUI module source files. + yui: {src: files ? files : grunt.moodleEnv.yuiSrc}, + }, + }); + + grunt.loadNpmTasks('grunt-eslint'); + + // On watch, we dynamically modify config to build only affected files. This + // method is slightly complicated to deal with multiple changed files at once (copied + // from the grunt-contrib-watch readme). + let changedFiles = Object.create(null); + const onChange = grunt.util._.debounce(function() { + const files = Object.keys(changedFiles); + grunt.config('eslint.amd.src', files); + grunt.config('eslint.yui.src', files); + changedFiles = Object.create(null); + }, 200); + + grunt.event.on('watch', (action, filepath) => { + changedFiles[filepath] = action; + onChange(); + }); +}; diff --git a/.grunt/tasks/gherkinlint.js b/.grunt/tasks/gherkinlint.js new file mode 100644 index 00000000000..cc269a5c47f --- /dev/null +++ b/.grunt/tasks/gherkinlint.js @@ -0,0 +1,89 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +module.exports = grunt => { + /** + * Get the list of feature files to pass to the gherkin linter. + * + * @returns {Array} + */ + const getGherkinLintTargets = () => { + if (grunt.moodleEnv.files) { + // Specific files were requested. Only check these. + return grunt.moodleEnv.files; + } + + if (grunt.moodleEnv.inComponent) { + return [`${grunt.moodleEnv.runDir}/tests/behat/*.feature`]; + } + + return ['**/tests/behat/*.feature']; + }; + + const handler = function() { + const done = this.async(); + const options = grunt.config('gherkinlint.options'); + + // Grab the gherkin-lint linter and required scaffolding. + const linter = require('gherkin-lint/dist/linter.js'); + const featureFinder = require('gherkin-lint/dist/feature-finder.js'); + const configParser = require('gherkin-lint/dist/config-parser.js'); + const formatter = require('gherkin-lint/dist/formatters/stylish.js'); + + // Run the linter. + return linter.lint( + featureFinder.getFeatureFiles(grunt.file.expand(options.files)), + configParser.getConfiguration(configParser.defaultConfigFileName) + ) + .then(results => { + // Print the results out uncondtionally. + formatter.printResults(results); + + return results; + }) + .then(results => { + // Report on the results. + // The done function takes a bool whereby a falsey statement causes the task to fail. + return results.every(result => result.errors.length === 0); + }) + .then(done); // eslint-disable-line promise/no-callback-in-promise + }; + + grunt.registerTask('gherkinlint', 'Run gherkinlint against the current directory', handler); + + grunt.config.set('gherkinlint', { + options: { + files: getGherkinLintTargets(), + } + }); + + grunt.config.merge({ + watch: { + gherkinlint: { + files: [grunt.moodleEnv.inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'], + tasks: ['gherkinlint'], + }, + }, + }); + + return handler; +}; diff --git a/.grunt/tasks/ignorefiles.js b/.grunt/tasks/ignorefiles.js new file mode 100644 index 00000000000..d8b9ec180ac --- /dev/null +++ b/.grunt/tasks/ignorefiles.js @@ -0,0 +1,59 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +module.exports = grunt => { + /** + * Generate ignore files (utilising thirdpartylibs.xml data) + */ + const handler = function() { + const path = require('path'); + const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js')); + + // An array of paths to third party directories. + const thirdPartyPaths = ComponentList.getThirdPartyPaths(); + + // Generate .eslintignore. + const eslintIgnores = [ + '# Generated by "grunt ignorefiles"', + // Do not ignore the .grunt directory. + '!/.grunt', + + // Ignore all yui/src meta directories and build directories. + '*/**/yui/src/*/meta/', + '*/**/build/', + ].concat(thirdPartyPaths); + grunt.file.write('.eslintignore', eslintIgnores.join('\n')); + + // Generate .stylelintignore. + const stylelintIgnores = [ + '# Generated by "grunt ignorefiles"', + '**/yui/build/*', + 'theme/boost/style/moodle.css', + 'theme/classic/style/moodle.css', + ].concat(thirdPartyPaths); + grunt.file.write('.stylelintignore', stylelintIgnores.join('\n')); + }; + + grunt.registerTask('ignorefiles', 'Generate ignore files for linters', handler); + + return handler; +}; diff --git a/.grunt/tasks/javascript.js b/.grunt/tasks/javascript.js new file mode 100644 index 00000000000..4cc309ca6a6 --- /dev/null +++ b/.grunt/tasks/javascript.js @@ -0,0 +1,141 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Function to generate the destination for the uglify task + * (e.g. build/file.min.js). This function will be passed to + * the rename property of files array when building dynamically: + * http://gruntjs.com/configuring-tasks#building-the-files-object-dynamically + * + * @param {String} destPath the current destination + * @param {String} srcPath the matched src path + * @return {String} The rewritten destination path. + */ +const babelRename = function(destPath, srcPath) { + destPath = srcPath.replace('src', 'build'); + destPath = destPath.replace('.js', '.min.js'); + return destPath; +}; + +module.exports = grunt => { + // Load the Shifter tasks. + require('./shifter')(grunt); + + // Load ESLint. + require('./eslint')(grunt); + + const path = require('path'); + + // Register JS tasks. + grunt.registerTask('yui', ['eslint:yui', 'shifter']); + grunt.registerTask('amd', ['eslint:amd', 'babel']); + grunt.registerTask('js', ['amd', 'yui']); + + // Register NPM tasks. + grunt.loadNpmTasks('grunt-contrib-uglify'); + grunt.loadNpmTasks('grunt-contrib-watch'); + + // Load the Babel tasks and config. + grunt.loadNpmTasks('grunt-babel'); + grunt.config.merge({ + babel: { + options: { + sourceMaps: true, + comments: false, + plugins: [ + 'transform-es2015-modules-amd-lazy', + 'system-import-transformer', + // This plugin modifies the Babel transpiling for "export default" + // so that if it's used then only the exported value is returned + // by the generated AMD module. + // + // It also adds the Moodle plugin name to the AMD module definition + // so that it can be imported as expected in other modules. + path.resolve('.grunt/babel-plugin-add-module-to-define.js'), + '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-syntax-import-meta', + ['@babel/plugin-proposal-class-properties', {'loose': false}], + '@babel/plugin-proposal-json-strings' + ], + presets: [ + ['minify', { + // This minification plugin needs to be disabled because it breaks the + // source map generation and causes invalid source maps to be output. + simplify: false, + builtIns: false + }], + ['@babel/preset-env', { + targets: { + browsers: [ + ">0.25%", + "last 2 versions", + "not ie <= 10", + "not op_mini all", + "not Opera > 0", + "not dead" + ] + }, + modules: false, + useBuiltIns: false + }] + ] + }, + dist: { + files: [{ + expand: true, + src: grunt.moodleEnv.files ? grunt.moodleEnv.files : grunt.moodleEnv.amdSrc, + rename: babelRename + }] + } + }, + }); + + grunt.config.merge({ + watch: { + amd: { + files: grunt.moodleEnv.inComponent + ? ['amd/src/*.js', 'amd/src/**/*.js'] + : ['**/amd/src/**/*.js'], + tasks: ['amd'] + }, + }, + }); + + // On watch, we dynamically modify config to build only affected files. This + // method is slightly complicated to deal with multiple changed files at once (copied + // from the grunt-contrib-watch readme). + let changedFiles = Object.create(null); + const onChange = grunt.util._.debounce(function() { + const files = Object.keys(changedFiles); + grunt.config('babel.dist.files', [{expand: true, src: files, rename: babelRename}]); + changedFiles = Object.create(null); + }, 200); + + grunt.event.on('watch', function(action, filepath) { + changedFiles[filepath] = action; + onChange(); + }); + + return { + babelRename, + }; +}; diff --git a/.grunt/tasks/sass.js b/.grunt/tasks/sass.js new file mode 100644 index 00000000000..cdb8bb1fe15 --- /dev/null +++ b/.grunt/tasks/sass.js @@ -0,0 +1,40 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +module.exports = grunt => { + grunt.loadNpmTasks('grunt-sass'); + + grunt.config.merge({ + sass: { + dist: { + files: { + "theme/boost/style/moodle.css": "theme/boost/scss/preset/default.scss", + "theme/classic/style/moodle.css": "theme/classic/scss/classicgrunt.scss" + } + }, + options: { + implementation: require('node-sass'), + includePaths: ["theme/boost/scss/", "theme/classic/scss/"] + } + }, + }); +}; diff --git a/.grunt/tasks/shifter.js b/.grunt/tasks/shifter.js new file mode 100644 index 00000000000..b364af5af32 --- /dev/null +++ b/.grunt/tasks/shifter.js @@ -0,0 +1,155 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/* eslint-env node */ + +module.exports = grunt => { + /** + * Shifter task. Is configured with a path to a specific file or a directory, + * in the case of a specific file it will work out the right module to be built. + * + * Note that this task runs the invidiaul shifter jobs async (becase it spawns + * so be careful to to call done(). + */ + const handler = function() { + const done = this.async(); + const options = grunt.config('shifter.options'); + const async = require('async'); + const path = require('path'); + + // Run the shifter processes one at a time to avoid confusing output. + async.eachSeries(options.paths, function(src, filedone) { + var args = []; + args.push(path.normalize(process.cwd() + '/node_modules/shifter/bin/shifter')); + + // Always ignore the node_modules directory. + args.push('--excludes', 'node_modules'); + + // Determine the most appropriate options to run with based upon the current location. + if (grunt.file.isMatch('**/yui/**/*.js', src)) { + // When passed a JS file, build our containing module (this happen with + // watch). + grunt.log.debug('Shifter passed a specific JS file'); + src = path.dirname(path.dirname(src)); + options.recursive = false; + } else if (grunt.file.isMatch('**/yui/src', src)) { + // When in a src directory --walk all modules. + grunt.log.debug('In a src directory'); + args.push('--walk'); + options.recursive = false; + } else if (grunt.file.isMatch('**/yui/src/*', src)) { + // When in module, only build our module. + grunt.log.debug('In a module directory'); + options.recursive = false; + } else if (grunt.file.isMatch('**/yui/src/*/js', src)) { + // When in module src, only build our module. + grunt.log.debug('In a source directory'); + src = path.dirname(src); + options.recursive = false; + } + + if (grunt.option('watch')) { + grunt.fail.fatal('The --watch option has been removed, please use `grunt watch` instead'); + } + + // Add the stderr option if appropriate + if (grunt.option('verbose')) { + args.push('--lint-stderr'); + } + + if (grunt.option('no-color')) { + args.push('--color=false'); + } + + var execShifter = function() { + + grunt.log.ok("Running shifter on " + src); + grunt.util.spawn({ + cmd: "node", + args: args, + opts: {cwd: src, stdio: 'inherit', env: process.env} + }, function(error, result, code) { + if (code) { + grunt.fail.fatal('Shifter failed with code: ' + code); + } else { + grunt.log.ok('Shifter build complete.'); + filedone(); + } + }); + }; + + // Actually run shifter. + if (!options.recursive) { + execShifter(); + } else { + // Check that there are yui modules otherwise shifter ends with exit code 1. + if (grunt.file.expand({cwd: src}, '**/yui/src/**/*.js').length > 0) { + args.push('--recursive'); + execShifter(); + } else { + grunt.log.ok('No YUI modules to build.'); + filedone(); + } + } + }, done); + }; + + // Register the shifter task. + grunt.registerTask('shifter', 'Run Shifter against the current directory', handler); + + // Configure it. + grunt.config.set('shifter', { + options: { + recursive: true, + // Shifter takes a relative path. + paths: grunt.moodleEnv.files ? grunt.moodleEnv.files : [grunt.moodleEnv.runDir] + } + }); + + grunt.config.merge({ + watch: { + yui: { + files: grunt.moodleEnv.inComponent + ? ['yui/src/*.json', 'yui/src/**/*.js'] + : ['**/yui/src/**/*.js'], + tasks: ['yui'] + }, + }, + }); + + // On watch, we dynamically modify config to build only affected files. This + // method is slightly complicated to deal with multiple changed files at once (copied + // from the grunt-contrib-watch readme). + let changedFiles = Object.create(null); + const onChange = grunt.util._.debounce(function() { + const files = Object.keys(changedFiles); + grunt.config('shifter.options.paths', files); + changedFiles = Object.create(null); + }, 200); + + grunt.event.on('watch', (action, filepath) => { + changedFiles[filepath] = action; + onChange(); + }); + + return handler; +}; diff --git a/.grunt/tasks/startup.js b/.grunt/tasks/startup.js new file mode 100644 index 00000000000..b532852a235 --- /dev/null +++ b/.grunt/tasks/startup.js @@ -0,0 +1,48 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +module.exports = grunt => { + /** + * Generate ignore files (utilising thirdpartylibs.xml data) + */ + const handler = function() { + const path = require('path'); + + // Are we in a YUI directory? + if (path.basename(path.resolve(grunt.moodleEnv.cwd, '../../')) == 'yui') { + grunt.task.run('yui'); + // Are we in an AMD directory? + } else if (grunt.moodleEnv.inAMD) { + grunt.task.run('amd'); + } else { + // Run them all!. + grunt.task.run('css'); + grunt.task.run('js'); + grunt.task.run('gherkinlint'); + } + }; + + // Register the startup task. + grunt.registerTask('startup', 'Run the correct tasks for the current directory', handler); + + return handler; +}; diff --git a/.grunt/tasks/style.js b/.grunt/tasks/style.js new file mode 100644 index 00000000000..05f713c32bd --- /dev/null +++ b/.grunt/tasks/style.js @@ -0,0 +1,29 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +module.exports = grunt => { + // Load the Style Lint tasks. + require('./stylelint')(grunt); + + // Load the SASS tasks. + require('./sass')(grunt); +}; diff --git a/.grunt/tasks/stylelint.js b/.grunt/tasks/stylelint.js new file mode 100644 index 00000000000..4da9067e644 --- /dev/null +++ b/.grunt/tasks/stylelint.js @@ -0,0 +1,181 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . +/* jshint node: true, browser: false */ +/* eslint-env node */ + +/** + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +module.exports = grunt => { + + const getCssConfigForFiles = files => { + return { + stylelint: { + css: { + // Use a fully-qualified path. + src: files, + options: { + configOverrides: { + rules: { + // These rules have to be disabled in .stylelintrc for scss compat. + "at-rule-no-unknown": true, + } + } + } + }, + }, + }; + }; + + const getScssConfigForFiles = files => { + return { + stylelint: { + scss: { + options: {syntax: 'scss'}, + src: files, + }, + }, + }; + }; + + /** + * Register any stylelint tasks. + * + * @param {Object} grunt + * @param {Array} files + * @param {String} fullRunDir + */ + const registerStyleLintTasks = () => { + const glob = require('glob'); + + // The stylelinters do not handle the case where a configuration was provided but no files were included. + // Keep track of whether any files were found. + let hasCss = false; + let hasScss = false; + + // The stylelint processors do not take a path argument. They always check all provided values. + // As a result we must check through each glob and determine if any files match the current directory. + const scssFiles = []; + const cssFiles = []; + + const requestedFiles = grunt.moodleEnv.files; + if (requestedFiles) { + // Grunt was called with a files argument. + // Check whether each of the requested files matches either the CSS or SCSS source file list. + + requestedFiles.forEach(changedFilePath => { + let matchesGlob; + + // Check whether this watched path matches any watched SCSS file. + matchesGlob = grunt.moodleEnv.scssSrc.some(watchedPathGlob => { + return glob.sync(watchedPathGlob).indexOf(changedFilePath) !== -1; + }); + if (matchesGlob) { + scssFiles.push(changedFilePath); + hasScss = true; + } + + // Check whether this watched path matches any watched CSS file. + matchesGlob = grunt.moodleEnv.cssSrc.some(watchedPathGlob => { + return glob.sync(watchedPathGlob).indexOf(changedFilePath) !== -1; + }); + if (matchesGlob) { + cssFiles.push(changedFilePath); + hasCss = true; + } + }); + } else { + // Grunt was called without a list of files. + // The start directory (runDir) may be a child dir of the project. + // Check each scssSrc file to see if it's in the start directory. + // This means that we can lint just mod/*/styles.css if started in the mod directory. + + grunt.moodleEnv.scssSrc.forEach(path => { + if (path.startsWith(grunt.moodleEnv.runDir)) { + scssFiles.push(path); + hasScss = true; + } + }); + + grunt.moodleEnv.cssSrc.forEach(path => { + if (path.startsWith(grunt.moodleEnv.runDir)) { + cssFiles.push(path); + hasCss = true; + } + }); + } + + // Register the tasks. + const scssTasks = ['sass']; + if (hasScss) { + grunt.config.merge(getScssConfigForFiles(scssFiles)); + scssTasks.unshift('stylelint:scss'); + } + + const cssTasks = []; + if (hasCss) { + grunt.config.merge(getCssConfigForFiles(cssFiles)); + cssTasks.push('stylelint:css'); + } + + // The tasks must be registered, even if empty to ensure a consistent command list. + // They jsut won't run anything. + grunt.registerTask('scss', scssTasks); + grunt.registerTask('rawcss', cssTasks); + }; + + // Register CSS tasks. + grunt.loadNpmTasks('grunt-stylelint'); + + // Register the style lint tasks. + registerStyleLintTasks(); + grunt.registerTask('css', ['scss', 'rawcss']); + + const getCoreThemeMatches = () => { + const scssMatch = 'scss/**/*.scss'; + + if (grunt.moodleEnv.inTheme) { + return [scssMatch]; + } + + if (grunt.moodleEnv.runDir.startsWith('theme')) { + return [`*/${scssMatch}`]; + } + + return [`theme/*/${scssMatch}`]; + }; + + // Add the watch configuration for rawcss, and scss. + grunt.config.merge({ + watch: { + rawcss: { + files: [ + '**/*.css', + ], + excludes: [ + '**/moodle.css', + '**/editor.css', + ], + tasks: ['rawcss'] + }, + scss: { + files: getCoreThemeMatches(), + tasks: ['scss'] + }, + }, + }); +}; diff --git a/.grunt/tasks/watch.js b/.grunt/tasks/watch.js new file mode 100644 index 00000000000..25c64876c71 --- /dev/null +++ b/.grunt/tasks/watch.js @@ -0,0 +1,272 @@ +/** + * This is a wrapper task to handle the grunt watch command. It attempts to use + * Watchman to monitor for file changes, if it's installed, because it's much faster. + * + * If Watchman isn't installed then it falls back to the grunt-contrib-watch file + * watcher for backwards compatibility. + */ + +/* eslint-env node */ + +module.exports = grunt => { + /** + * This is a wrapper task to handle the grunt watch command. It attempts to use + * Watchman to monitor for file changes, if it's installed, because it's much faster. + * + * If Watchman isn't installed then it falls back to the grunt-contrib-watch file + * watcher for backwards compatibility. + */ + const watchHandler = function() { + const async = require('async'); + const watchTaskDone = this.async(); + let watchInitialised = false; + let watchTaskQueue = {}; + let processingQueue = false; + + const watchman = require('fb-watchman'); + const watchmanClient = new watchman.Client(); + + // Grab the tasks and files that have been queued up and execute them. + var processWatchTaskQueue = function() { + if (!Object.keys(watchTaskQueue).length || processingQueue) { + // If there is nothing in the queue or we're already processing then wait. + return; + } + + processingQueue = true; + + // Grab all tasks currently in the queue. + var queueToProcess = watchTaskQueue; + // Reset the queue. + watchTaskQueue = {}; + + async.forEachSeries( + Object.keys(queueToProcess), + function(task, next) { + var files = queueToProcess[task]; + var filesOption = '--files=' + files.join(','); + grunt.log.ok('Running task ' + task + ' for files ' + filesOption); + + // Spawn the task in a child process so that it doesn't kill this one + // if it failed. + grunt.util.spawn( + { + // Spawn with the grunt bin. + grunt: true, + // Run from current working dir and inherit stdio from process. + opts: { + cwd: grunt.moodleEnv.fullRunDir, + stdio: 'inherit' + }, + args: [task, filesOption] + }, + function(err, res, code) { + if (code !== 0) { + // The grunt task failed. + grunt.log.error(err); + } + + // Move on to the next task. + next(); + } + ); + }, + function() { + // No longer processing. + processingQueue = false; + // Once all of the tasks are done then recurse just in case more tasks + // were queued while we were processing. + processWatchTaskQueue(); + } + ); + }; + + const originalWatchConfig = grunt.config.get(['watch']); + const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) { + if (key == 'options') { + return carry; + } + + const value = originalWatchConfig[key]; + + const taskNames = value.tasks; + const files = value.files; + let excludes = []; + if (value.excludes) { + excludes = value.excludes; + } + + taskNames.forEach(function(taskName) { + carry[taskName] = { + files, + excludes, + }; + }); + + return carry; + }, {}); + + watchmanClient.on('error', function(error) { + // We have to add an error handler here and parse the error string because the + // example way from the docs to check if Watchman is installed doesn't actually work!! + // See: https://github.com/facebook/watchman/issues/509 + if (error.message.match('Watchman was not found')) { + // If watchman isn't installed then we should fallback to the other watch task. + grunt.log.ok('It is recommended that you install Watchman for better performance using the "watch" command.'); + + // Fallback to the old grunt-contrib-watch task. + grunt.renameTask('watch-grunt', 'watch'); + grunt.task.run(['watch']); + // This task is finished. + watchTaskDone(0); + } else { + grunt.log.error(error); + // Fatal error. + watchTaskDone(1); + } + }); + + watchmanClient.on('subscription', function(resp) { + if (resp.subscription !== 'grunt-watch') { + return; + } + + resp.files.forEach(function(file) { + grunt.log.ok('File changed: ' + file.name); + + var fullPath = grunt.moodleEnv.fullRunDir + '/' + file.name; + Object.keys(watchConfig).forEach(function(task) { + + const fileGlobs = watchConfig[task].files; + var match = fileGlobs.some(function(fileGlob) { + return grunt.file.isMatch(`**/${fileGlob}`, fullPath); + }); + + if (match) { + // If we are watching a subdirectory then the file.name will be relative + // to that directory. However the grunt tasks expect the file paths to be + // relative to the Gruntfile.js location so let's normalise them before + // adding them to the queue. + var relativePath = fullPath.replace(grunt.moodleEnv.gruntFilePath + '/', ''); + if (task in watchTaskQueue) { + if (!watchTaskQueue[task].includes(relativePath)) { + watchTaskQueue[task] = watchTaskQueue[task].concat(relativePath); + } + } else { + watchTaskQueue[task] = [relativePath]; + } + } + }); + }); + + processWatchTaskQueue(); + }); + + process.on('SIGINT', function() { + // Let the user know that they may need to manually stop the Watchman daemon if they + // no longer want it running. + if (watchInitialised) { + grunt.log.ok('The Watchman daemon may still be running and may need to be stopped manually.'); + } + + process.exit(); + }); + + // Initiate the watch on the current directory. + watchmanClient.command(['watch-project', grunt.moodleEnv.fullRunDir], function(watchError, watchResponse) { + if (watchError) { + grunt.log.error('Error initiating watch:', watchError); + watchTaskDone(1); + return; + } + + if ('warning' in watchResponse) { + grunt.log.error('warning: ', watchResponse.warning); + } + + var watch = watchResponse.watch; + var relativePath = watchResponse.relative_path; + watchInitialised = true; + + watchmanClient.command(['clock', watch], function(clockError, clockResponse) { + if (clockError) { + grunt.log.error('Failed to query clock:', clockError); + watchTaskDone(1); + return; + } + + // Generate the expression query used by watchman. + // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples. + // We generate an expression to match any value in the files list of all of our tasks, but excluding + // all value in the excludes list of that task. + // + // [anyof, [ + // [allof, [ + // [anyof, [ + // ['match', validPath, 'wholename'], + // ['match', validPath, 'wholename'], + // ], + // [not, + // [anyof, [ + // ['match', invalidPath, 'wholename'], + // ['match', invalidPath, 'wholename'], + // ], + // ], + // ], + var matchWholeName = fileGlob => ['match', fileGlob, 'wholename']; + var matches = Object.keys(watchConfig).map(function(task) { + const matchAll = []; + matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName))); + + if (watchConfig[task].excludes.length) { + matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]); + } + + return ['allof'].concat(matchAll); + }); + + matches = ['anyof'].concat(matches); + + var sub = { + expression: matches, + // Which fields we're interested in. + fields: ["name", "size", "type"], + // Add our time constraint. + since: clockResponse.clock + }; + + if (relativePath) { + /* eslint-disable camelcase */ + sub.relative_root = relativePath; + } + + watchmanClient.command(['subscribe', watch, 'grunt-watch', sub], function(subscribeError) { + if (subscribeError) { + // Probably an error in the subscription criteria. + grunt.log.error('failed to subscribe: ', subscribeError); + watchTaskDone(1); + return; + } + + grunt.log.ok('Listening for changes to files in ' + grunt.moodleEnv.fullRunDir); + }); + }); + }); + }; + + // Rename the grunt-contrib-watch "watch" task because we're going to wrap it. + grunt.renameTask('watch', 'watch-grunt'); + + // Register the new watch handler. + grunt.registerTask('watch', 'Run tasks on file changes', watchHandler); + + grunt.config.merge({ + watch: { + options: { + nospawn: true // We need not to spawn so config can be changed dynamically. + }, + }, + }); + + return watchHandler; +}; diff --git a/Gruntfile.js b/Gruntfile.js index a7b728feaba..89836af0e4f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -16,147 +16,129 @@ /* eslint-env node */ /** + * Grunt configuration for Moodle. + * * @copyright 2014 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -/* eslint-env node */ - /** - * Calculate the cwd, taking into consideration the `root` option (for Windows). + * Setup the Grunt Moodle environment. * - * @param {Object} grunt - * @returns {String} The current directory as best we can determine + * @param {Grunt} grunt + * @returns {Object} */ -const getCwd = grunt => { +const setupMoodleEnvironment = grunt => { const fs = require('fs'); const path = require('path'); + const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js')); - let cwd = fs.realpathSync(process.env.PWD || process.cwd()); + const getAmdConfiguration = () => { + // If the cwd is the amd directory in the current component then it will be empty. + // If the cwd is a child of the component's AMD directory, the relative directory will not start with .. + let inAMD = !path.relative(`${componentDirectory}/amd`, cwd).startsWith('..'); - // Windows users can't run grunt in a subdirectory, so allow them to set - // the root by passing --root=path/to/dir. - if (grunt.option('root')) { - const root = grunt.option('root'); - if (grunt.file.exists(__dirname, root)) { - cwd = fs.realpathSync(path.join(__dirname, root)); - grunt.log.ok('Setting root to ' + cwd); + // Globbing pattern for matching all AMD JS source files. + let amdSrc = []; + if (inComponent) { + amdSrc.push( + componentDirectory + "/amd/src/*.js", + componentDirectory + "/amd/src/**/*.js" + ); } else { - grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist'); + amdSrc = ComponentList.getAmdSrcGlobList(); } - } - return cwd; -}; - -/** - * Register any stylelint tasks. - * - * @param {Object} grunt - * @param {Array} files - * @param {String} fullRunDir - */ -const registerStyleLintTasks = (grunt, files, fullRunDir) => { - const getCssConfigForFiles = files => { return { - stylelint: { - css: { - // Use a fully-qualified path. - src: files, - options: { - configOverrides: { - rules: { - // These rules have to be disabled in .stylelintrc for scss compat. - "at-rule-no-unknown": true, - } - } - } - }, - }, + inAMD, + amdSrc, }; }; - const getScssConfigForFiles = files => { + const getYuiConfiguration = () => { + let yuiSrc = []; + if (inComponent) { + yuiSrc.push(componentDirectory + "/yui/src/**/*.js"); + } else { + yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/'); + } + return { - stylelint: { - scss: { - options: {syntax: 'scss'}, - src: files, - }, - }, + yuiSrc, }; }; - let hasCss = true; - let hasScss = true; + const getStyleConfiguration = () => { + const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js')); + // Build the cssSrc and scssSrc. + // Valid paths are: + // [component]/styles.css; and either + // [theme/[themename]]/scss/**/*.scss; or + // [theme/[themename]]/style/*.css. + // + // If a theme has scss, then it is assumed that the style directory contains generated content. + let cssSrc = []; + let scssSrc = []; - if (files) { - // Specific files were passed. Just set them up. - grunt.config.merge(getCssConfigForFiles(files)); - grunt.config.merge(getScssConfigForFiles(files)); - } else { - // The stylelint system does not handle the case where there was no file to lint. - // Check whether there are any files to lint in the current directory. - const glob = require('glob'); + const checkComponentDirectory = componentDirectory => { + const isTheme = componentDirectory.startsWith('theme/'); + if (isTheme) { + const scssDirectory = `${componentDirectory}/scss`; - const scssSrc = []; - glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path)); + if (fs.existsSync(scssDirectory)) { + // This theme has an SCSS directory. + // Include all scss files within it recursively, but do not check for css files. + scssSrc.push(`${scssDirectory}/*.scss`); + scssSrc.push(`${scssDirectory}/**/*.scss`); + } else { + // This theme has no SCSS directory. + // Only hte CSS files in the top-level directory are checked. + cssSrc.push(`${componentDirectory}/style/*.css`); + } + } else { + // This is not a theme. + // All other plugin types are restricted to a single styles.css in their top level. + cssSrc.push(`${componentDirectory}/styles.css`); + } + }; - if (scssSrc.length) { - grunt.config.merge(getScssConfigForFiles(scssSrc)); + if (inComponent) { + checkComponentDirectory(componentDirectory); } else { - hasScss = false; + ComponentList.getComponentPaths(`${gruntFilePath}/`).forEach(componentPath => { + checkComponentDirectory(componentPath); + }); } - const cssSrc = []; - glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path)); + return { + cssSrc, + scssSrc, + }; + }; - if (cssSrc.length) { - grunt.config.merge(getCssConfigForFiles(cssSrc)); - } else { - hasCss = false; + /** + * Calculate the cwd, taking into consideration the `root` option (for Windows). + * + * @param {Object} grunt + * @returns {String} The current directory as best we can determine + */ + const getCwd = grunt => { + let cwd = fs.realpathSync(process.env.PWD || process.cwd()); + + // Windows users can't run grunt in a subdirectory, so allow them to set + // the root by passing --root=path/to/dir. + if (grunt.option('root')) { + const root = grunt.option('root'); + if (grunt.file.exists(__dirname, root)) { + cwd = fs.realpathSync(path.join(__dirname, root)); + grunt.log.ok('Setting root to ' + cwd); + } else { + grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist'); + } } - } - const scssTasks = ['sass']; - if (hasScss) { - scssTasks.unshift('stylelint:scss'); - } - grunt.registerTask('scss', scssTasks); - - const cssTasks = []; - if (hasCss) { - cssTasks.push('stylelint:css'); - } - grunt.registerTask('rawcss', cssTasks); - - grunt.registerTask('css', ['scss', 'rawcss']); -}; - -/** - * Grunt configuration. - * - * @param {Object} grunt - */ -module.exports = function(grunt) { - const path = require('path'); - const tasks = {}; - const async = require('async'); - const DOMParser = require('xmldom').DOMParser; - const xpath = require('xpath'); - const semver = require('semver'); - const watchman = require('fb-watchman'); - const watchmanClient = new watchman.Client(); - const fs = require('fs'); - const ComponentList = require(path.resolve('GruntfileComponents.js')); - const sass = require('node-sass'); - - // Verify the node version is new enough. - var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node); - var actual = semver.valid(process.version); - if (!semver.satisfies(actual, expected)) { - grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual); - } + return cwd; + }; // Detect directories: // * gruntFilePath The real path on disk to this Gruntfile.js @@ -171,8 +153,19 @@ module.exports = function(grunt) { const relativeCwd = path.relative(gruntFilePath, cwd); const componentDirectory = ComponentList.getOwningComponentDirectory(relativeCwd); const inComponent = !!componentDirectory; + const inTheme = !!componentDirectory && componentDirectory.startsWith('theme/'); const runDir = inComponent ? componentDirectory : relativeCwd; const fullRunDir = fs.realpathSync(gruntFilePath + path.sep + runDir); + const {inAMD, amdSrc} = getAmdConfiguration(); + const {yuiSrc} = getYuiConfiguration(); + const {cssSrc, scssSrc} = getStyleConfiguration(); + + let files = null; + if (grunt.option('files')) { + // Accept a comma separated list of files to process. + files = grunt.option('files').split(','); + } + grunt.log.debug('============================================================================'); grunt.log.debug(`= Node version: ${process.versions.node}`); grunt.log.debug(`= grunt version: ${grunt.package.version}`); @@ -192,667 +185,77 @@ module.exports = function(grunt) { grunt.log.ok(`Running tasks for component directory ${componentDirectory}`); } - let files = null; - if (grunt.option('files')) { - // Accept a comma separated list of files to process. - files = grunt.option('files').split(','); + return { + amdSrc, + componentDirectory, + cwd, + cssSrc, + files, + fullRunDir, + gruntFilePath, + inAMD, + inComponent, + inTheme, + relativeCwd, + runDir, + scssSrc, + yuiSrc, + }; +}; + +/** + * Verify tha tthe current NodeJS version matches the required version in package.json. + * + * @param {Grunt} grunt + */ +const verifyNodeVersion = grunt => { + const semver = require('semver'); + + // Verify the node version is new enough. + var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node); + var actual = semver.valid(process.version); + if (!semver.satisfies(actual, expected)) { + grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual); } +}; - // If the cwd is the amd directory in the current component then it will be empty. - // If the cwd is a child of the component's AMD directory, the relative directory will not start with .. - const inAMD = !path.relative(`${componentDirectory}/amd`, cwd).startsWith('..'); +/** + * Grunt configuration. + * + * @param {Grunt} grunt + */ +module.exports = function(grunt) { + // Verify that the Node version meets our requirements. + verifyNodeVersion(grunt); - // Globbing pattern for matching all AMD JS source files. - let amdSrc = []; - if (inComponent) { - amdSrc.push(componentDirectory + "/amd/src/*.js"); - amdSrc.push(componentDirectory + "/amd/src/**/*.js"); - } else { - amdSrc = ComponentList.getAmdSrcGlobList(); - } - - let yuiSrc = []; - if (inComponent) { - yuiSrc.push(componentDirectory + "/yui/src/**/*.js"); - } else { - yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/'); - } + // Setup the Moodle environemnt within the Grunt object. + grunt.moodleEnv = setupMoodleEnvironment(grunt); /** - * Function to generate the destination for the uglify task - * (e.g. build/file.min.js). This function will be passed to - * the rename property of files array when building dynamically: - * http://gruntjs.com/configuring-tasks#building-the-files-object-dynamically + * Add the named task. * - * @param {String} destPath the current destination - * @param {String} srcPath the matched src path - * @return {String} The rewritten destination path. + * @param {string} name + * @param {Grunt} grunt */ - var babelRename = function(destPath, srcPath) { - destPath = srcPath.replace('src', 'build'); - destPath = destPath.replace('.js', '.min.js'); - return destPath; + const addTask = (name, grunt) => { + const path = require('path'); + const taskPath = path.resolve(`./.grunt/tasks/${name}.js`); + + grunt.log.debug(`Including tasks for ${name} from ${taskPath}`); + + require(path.resolve(`./.grunt/tasks/${name}.js`))(grunt); }; - /** - * Find thirdpartylibs.xml and generate an array of paths contained within - * them (used to generate ignore files and so on). - * - * @return {array} The list of thirdparty paths. - */ - var getThirdPartyPathsFromXML = function() { - const thirdpartyfiles = ComponentList.getThirdPartyLibsList(gruntFilePath + '/'); - const libs = ['node_modules/', 'vendor/']; - thirdpartyfiles.forEach(function(file) { - const dirname = path.dirname(file); + // Add Moodle task configuration. + addTask('gherkinlint', grunt); + addTask('ignorefiles', grunt); - const doc = new DOMParser().parseFromString(grunt.file.read(file)); - const nodes = xpath.select("/libraries/library/location/text()", doc); + addTask('javascript', grunt); + addTask('style', grunt); - nodes.forEach(function(node) { - let lib = path.posix.join(dirname, node.toString()); - if (grunt.file.isDir(lib)) { - // Ensure trailing slash on dirs. - lib = lib.replace(/\/?$/, '/'); - } - - // Look for duplicate paths before adding to array. - if (libs.indexOf(lib) === -1) { - libs.push(lib); - } - }); - }); - - return libs; - }; - - /** - * Get the list of feature files to pass to the gherkin linter. - * - * @returns {Array} - */ - const getGherkinLintTargets = () => { - if (files) { - // Specific files were requested. Only check these. - return files; - } - - if (inComponent) { - return [`${runDir}/tests/behat/*.feature`]; - } - - return ['**/tests/behat/*.feature']; - }; - - // Project configuration. - grunt.initConfig({ - eslint: { - // Even though warnings dont stop the build we don't display warnings by default because - // at this moment we've got too many core warnings. - // To display warnings call: grunt eslint --show-lint-warnings - // To fail on warnings call: grunt eslint --max-lint-warnings=0 - // Also --max-lint-warnings=-1 can be used to display warnings but not fail. - options: { - quiet: (!grunt.option('show-lint-warnings')) && (typeof grunt.option('max-lint-warnings') === 'undefined'), - maxWarnings: ((typeof grunt.option('max-lint-warnings') !== 'undefined') ? grunt.option('max-lint-warnings') : -1) - }, - amd: {src: files ? files : amdSrc}, - // Check YUI module source files. - yui: {src: files ? files : yuiSrc}, - }, - babel: { - options: { - sourceMaps: true, - comments: false, - plugins: [ - 'transform-es2015-modules-amd-lazy', - 'system-import-transformer', - // This plugin modifies the Babel transpiling for "export default" - // so that if it's used then only the exported value is returned - // by the generated AMD module. - // - // It also adds the Moodle plugin name to the AMD module definition - // so that it can be imported as expected in other modules. - path.resolve('babel-plugin-add-module-to-define.js'), - '@babel/plugin-syntax-dynamic-import', - '@babel/plugin-syntax-import-meta', - ['@babel/plugin-proposal-class-properties', {'loose': false}], - '@babel/plugin-proposal-json-strings' - ], - presets: [ - ['minify', { - // This minification plugin needs to be disabled because it breaks the - // source map generation and causes invalid source maps to be output. - simplify: false, - builtIns: false - }], - ['@babel/preset-env', { - targets: { - browsers: [ - ">0.25%", - "last 2 versions", - "not ie <= 10", - "not op_mini all", - "not Opera > 0", - "not dead" - ] - }, - modules: false, - useBuiltIns: false - }] - ] - }, - dist: { - files: [{ - expand: true, - src: files ? files : amdSrc, - rename: babelRename - }] - } - }, - sass: { - dist: { - files: { - "theme/boost/style/moodle.css": "theme/boost/scss/preset/default.scss", - "theme/classic/style/moodle.css": "theme/classic/scss/classicgrunt.scss" - } - }, - options: { - implementation: sass, - includePaths: ["theme/boost/scss/", "theme/classic/scss/"] - } - }, - watch: { - options: { - nospawn: true // We need not to spawn so config can be changed dynamically. - }, - amd: { - files: inComponent - ? ['amd/src/*.js', 'amd/src/**/*.js'] - : ['**/amd/src/**/*.js'], - tasks: ['amd'] - }, - boost: { - files: [inComponent ? 'scss/**/*.scss' : 'theme/boost/scss/**/*.scss'], - tasks: ['scss'] - }, - rawcss: { - files: [ - '**/*.css', - ], - excludes: [ - '**/moodle.css', - '**/editor.css', - ], - tasks: ['rawcss'] - }, - yui: { - files: inComponent - ? ['yui/src/*.json', 'yui/src/**/*.js'] - : ['**/yui/src/**/*.js'], - tasks: ['yui'] - }, - gherkinlint: { - files: [inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'], - tasks: ['gherkinlint'] - } - }, - shifter: { - options: { - recursive: true, - // Shifter takes a relative path. - paths: files ? files : [runDir] - } - }, - gherkinlint: { - options: { - files: getGherkinLintTargets(), - } - }, - }); - - /** - * Generate ignore files (utilising thirdpartylibs.xml data) - */ - tasks.ignorefiles = function() { - // An array of paths to third party directories. - const thirdPartyPaths = getThirdPartyPathsFromXML(); - // Generate .eslintignore. - const eslintIgnores = [ - '# Generated by "grunt ignorefiles"', - '*/**/yui/src/*/meta/', - '*/**/build/', - ].concat(thirdPartyPaths); - grunt.file.write('.eslintignore', eslintIgnores.join('\n')); - - // Generate .stylelintignore. - const stylelintIgnores = [ - '# Generated by "grunt ignorefiles"', - '**/yui/build/*', - 'theme/boost/style/moodle.css', - 'theme/classic/style/moodle.css', - ].concat(thirdPartyPaths); - grunt.file.write('.stylelintignore', stylelintIgnores.join('\n')); - }; - - /** - * Shifter task. Is configured with a path to a specific file or a directory, - * in the case of a specific file it will work out the right module to be built. - * - * Note that this task runs the invidiaul shifter jobs async (becase it spawns - * so be careful to to call done(). - */ - tasks.shifter = function() { - var done = this.async(), - options = grunt.config('shifter.options'); - - // Run the shifter processes one at a time to avoid confusing output. - async.eachSeries(options.paths, function(src, filedone) { - var args = []; - args.push(path.normalize(__dirname + '/node_modules/shifter/bin/shifter')); - - // Always ignore the node_modules directory. - args.push('--excludes', 'node_modules'); - - // Determine the most appropriate options to run with based upon the current location. - if (grunt.file.isMatch('**/yui/**/*.js', src)) { - // When passed a JS file, build our containing module (this happen with - // watch). - grunt.log.debug('Shifter passed a specific JS file'); - src = path.dirname(path.dirname(src)); - options.recursive = false; - } else if (grunt.file.isMatch('**/yui/src', src)) { - // When in a src directory --walk all modules. - grunt.log.debug('In a src directory'); - args.push('--walk'); - options.recursive = false; - } else if (grunt.file.isMatch('**/yui/src/*', src)) { - // When in module, only build our module. - grunt.log.debug('In a module directory'); - options.recursive = false; - } else if (grunt.file.isMatch('**/yui/src/*/js', src)) { - // When in module src, only build our module. - grunt.log.debug('In a source directory'); - src = path.dirname(src); - options.recursive = false; - } - - if (grunt.option('watch')) { - grunt.fail.fatal('The --watch option has been removed, please use `grunt watch` instead'); - } - - // Add the stderr option if appropriate - if (grunt.option('verbose')) { - args.push('--lint-stderr'); - } - - if (grunt.option('no-color')) { - args.push('--color=false'); - } - - var execShifter = function() { - - grunt.log.ok("Running shifter on " + src); - grunt.util.spawn({ - cmd: "node", - args: args, - opts: {cwd: src, stdio: 'inherit', env: process.env} - }, function(error, result, code) { - if (code) { - grunt.fail.fatal('Shifter failed with code: ' + code); - } else { - grunt.log.ok('Shifter build complete.'); - filedone(); - } - }); - }; - - // Actually run shifter. - if (!options.recursive) { - execShifter(); - } else { - // Check that there are yui modules otherwise shifter ends with exit code 1. - if (grunt.file.expand({cwd: src}, '**/yui/src/**/*.js').length > 0) { - args.push('--recursive'); - execShifter(); - } else { - grunt.log.ok('No YUI modules to build.'); - filedone(); - } - } - }, done); - }; - - tasks.gherkinlint = function() { - const done = this.async(); - const options = grunt.config('gherkinlint.options'); - - // Grab the gherkin-lint linter and required scaffolding. - const linter = require('gherkin-lint/dist/linter.js'); - const featureFinder = require('gherkin-lint/dist/feature-finder.js'); - const configParser = require('gherkin-lint/dist/config-parser.js'); - const formatter = require('gherkin-lint/dist/formatters/stylish.js'); - - // Run the linter. - return linter.lint( - featureFinder.getFeatureFiles(grunt.file.expand(options.files)), - configParser.getConfiguration(configParser.defaultConfigFileName) - ) - .then(results => { - // Print the results out uncondtionally. - formatter.printResults(results); - - return results; - }) - .then(results => { - // Report on the results. - // The done function takes a bool whereby a falsey statement causes the task to fail. - return results.every(result => result.errors.length === 0); - }) - .then(done); // eslint-disable-line promise/no-callback-in-promise - }; - - tasks.startup = function() { - // Are we in a YUI directory? - if (path.basename(path.resolve(cwd, '../../')) == 'yui') { - grunt.task.run('yui'); - // Are we in an AMD directory? - } else if (inAMD) { - grunt.task.run('amd'); - } else { - // Run them all!. - grunt.task.run('css'); - grunt.task.run('js'); - grunt.task.run('gherkinlint'); - } - }; - - /** - * This is a wrapper task to handle the grunt watch command. It attempts to use - * Watchman to monitor for file changes, if it's installed, because it's much faster. - * - * If Watchman isn't installed then it falls back to the grunt-contrib-watch file - * watcher for backwards compatibility. - */ - tasks.watch = function() { - var watchTaskDone = this.async(); - var watchInitialised = false; - var watchTaskQueue = {}; - var processingQueue = false; - - // Grab the tasks and files that have been queued up and execute them. - var processWatchTaskQueue = function() { - if (!Object.keys(watchTaskQueue).length || processingQueue) { - // If there is nothing in the queue or we're already processing then wait. - return; - } - - processingQueue = true; - - // Grab all tasks currently in the queue. - var queueToProcess = watchTaskQueue; - // Reset the queue. - watchTaskQueue = {}; - - async.forEachSeries( - Object.keys(queueToProcess), - function(task, next) { - var files = queueToProcess[task]; - var filesOption = '--files=' + files.join(','); - grunt.log.ok('Running task ' + task + ' for files ' + filesOption); - - // Spawn the task in a child process so that it doesn't kill this one - // if it failed. - grunt.util.spawn( - { - // Spawn with the grunt bin. - grunt: true, - // Run from current working dir and inherit stdio from process. - opts: { - cwd: fullRunDir, - stdio: 'inherit' - }, - args: [task, filesOption] - }, - function(err, res, code) { - if (code !== 0) { - // The grunt task failed. - grunt.log.error(err); - } - - // Move on to the next task. - next(); - } - ); - }, - function() { - // No longer processing. - processingQueue = false; - // Once all of the tasks are done then recurse just in case more tasks - // were queued while we were processing. - processWatchTaskQueue(); - } - ); - }; - - const originalWatchConfig = grunt.config.get(['watch']); - const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) { - if (key == 'options') { - return carry; - } - - const value = originalWatchConfig[key]; - - const taskNames = value.tasks; - const files = value.files; - let excludes = []; - if (value.excludes) { - excludes = value.excludes; - } - - taskNames.forEach(function(taskName) { - carry[taskName] = { - files, - excludes, - }; - }); - - return carry; - }, {}); - - watchmanClient.on('error', function(error) { - // We have to add an error handler here and parse the error string because the - // example way from the docs to check if Watchman is installed doesn't actually work!! - // See: https://github.com/facebook/watchman/issues/509 - if (error.message.match('Watchman was not found')) { - // If watchman isn't installed then we should fallback to the other watch task. - grunt.log.ok('It is recommended that you install Watchman for better performance using the "watch" command.'); - - // Fallback to the old grunt-contrib-watch task. - grunt.renameTask('watch-grunt', 'watch'); - grunt.task.run(['watch']); - // This task is finished. - watchTaskDone(0); - } else { - grunt.log.error(error); - // Fatal error. - watchTaskDone(1); - } - }); - - watchmanClient.on('subscription', function(resp) { - if (resp.subscription !== 'grunt-watch') { - return; - } - - resp.files.forEach(function(file) { - grunt.log.ok('File changed: ' + file.name); - - var fullPath = fullRunDir + '/' + file.name; - Object.keys(watchConfig).forEach(function(task) { - - const fileGlobs = watchConfig[task].files; - var match = fileGlobs.some(function(fileGlob) { - return grunt.file.isMatch(`**/${fileGlob}`, fullPath); - }); - - if (match) { - // If we are watching a subdirectory then the file.name will be relative - // to that directory. However the grunt tasks expect the file paths to be - // relative to the Gruntfile.js location so let's normalise them before - // adding them to the queue. - var relativePath = fullPath.replace(gruntFilePath + '/', ''); - if (task in watchTaskQueue) { - if (!watchTaskQueue[task].includes(relativePath)) { - watchTaskQueue[task] = watchTaskQueue[task].concat(relativePath); - } - } else { - watchTaskQueue[task] = [relativePath]; - } - } - }); - }); - - processWatchTaskQueue(); - }); - - process.on('SIGINT', function() { - // Let the user know that they may need to manually stop the Watchman daemon if they - // no longer want it running. - if (watchInitialised) { - grunt.log.ok('The Watchman daemon may still be running and may need to be stopped manually.'); - } - - process.exit(); - }); - - // Initiate the watch on the current directory. - watchmanClient.command(['watch-project', fullRunDir], function(watchError, watchResponse) { - if (watchError) { - grunt.log.error('Error initiating watch:', watchError); - watchTaskDone(1); - return; - } - - if ('warning' in watchResponse) { - grunt.log.error('warning: ', watchResponse.warning); - } - - var watch = watchResponse.watch; - var relativePath = watchResponse.relative_path; - watchInitialised = true; - - watchmanClient.command(['clock', watch], function(clockError, clockResponse) { - if (clockError) { - grunt.log.error('Failed to query clock:', clockError); - watchTaskDone(1); - return; - } - - // Generate the expression query used by watchman. - // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples. - // We generate an expression to match any value in the files list of all of our tasks, but excluding - // all value in the excludes list of that task. - // - // [anyof, [ - // [allof, [ - // [anyof, [ - // ['match', validPath, 'wholename'], - // ['match', validPath, 'wholename'], - // ], - // [not, - // [anyof, [ - // ['match', invalidPath, 'wholename'], - // ['match', invalidPath, 'wholename'], - // ], - // ], - // ], - var matchWholeName = fileGlob => ['match', fileGlob, 'wholename']; - var matches = Object.keys(watchConfig).map(function(task) { - const matchAll = []; - matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName))); - - if (watchConfig[task].excludes.length) { - matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]); - } - - return ['allof'].concat(matchAll); - }); - - matches = ['anyof'].concat(matches); - - var sub = { - expression: matches, - // Which fields we're interested in. - fields: ["name", "size", "type"], - // Add our time constraint. - since: clockResponse.clock - }; - - if (relativePath) { - /* eslint-disable camelcase */ - sub.relative_root = relativePath; - } - - watchmanClient.command(['subscribe', watch, 'grunt-watch', sub], function(subscribeError) { - if (subscribeError) { - // Probably an error in the subscription criteria. - grunt.log.error('failed to subscribe: ', subscribeError); - watchTaskDone(1); - return; - } - - grunt.log.ok('Listening for changes to files in ' + fullRunDir); - }); - }); - }); - }; - - // On watch, we dynamically modify config to build only affected files. This - // method is slightly complicated to deal with multiple changed files at once (copied - // from the grunt-contrib-watch readme). - var changedFiles = Object.create(null); - var onChange = grunt.util._.debounce(function() { - var files = Object.keys(changedFiles); - grunt.config('eslint.amd.src', files); - grunt.config('eslint.yui.src', files); - grunt.config('shifter.options.paths', files); - grunt.config('gherkinlint.options.files', files); - grunt.config('babel.dist.files', [{expand: true, src: files, rename: babelRename}]); - changedFiles = Object.create(null); - }, 200); - - grunt.event.on('watch', function(action, filepath) { - changedFiles[filepath] = action; - onChange(); - }); - - // Register NPM tasks. - grunt.loadNpmTasks('grunt-contrib-uglify'); - grunt.loadNpmTasks('grunt-contrib-watch'); - grunt.loadNpmTasks('grunt-sass'); - grunt.loadNpmTasks('grunt-eslint'); - grunt.loadNpmTasks('grunt-stylelint'); - grunt.loadNpmTasks('grunt-babel'); - - // Rename the grunt-contrib-watch "watch" task because we're going to wrap it. - grunt.renameTask('watch', 'watch-grunt'); - - // Register JS tasks. - grunt.registerTask('shifter', 'Run Shifter against the current directory', tasks.shifter); - grunt.registerTask('gherkinlint', 'Run gherkinlint against the current directory', tasks.gherkinlint); - grunt.registerTask('ignorefiles', 'Generate ignore files for linters', tasks.ignorefiles); - grunt.registerTask('watch', 'Run tasks on file changes', tasks.watch); - grunt.registerTask('yui', ['eslint:yui', 'shifter']); - grunt.registerTask('amd', ['eslint:amd', 'babel']); - grunt.registerTask('js', ['amd', 'yui']); - - // Register CSS tasks. - registerStyleLintTasks(grunt, files, fullRunDir); - - // Register the startup task. - grunt.registerTask('startup', 'Run the correct tasks for the current directory', tasks.startup); + addTask('watch', grunt); + addTask('startup', grunt); // Register the default task. grunt.registerTask('default', ['startup']);