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']);