From 61fca0e05c02e3028de0f7cb617bf21f25295497 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 19 Mar 2021 08:14:11 +0800 Subject: [PATCH 1/2] MDL-68496 grunt: Restructure grunt tasks into subdirectories Prior to this change all Grunt features were in a single Gruntfile.js but this has become difficult to manage and maintain. This commit moves the existing dependencies for component calculation and babel moduel definition into a new .grunt directory, and restructures the existing tasks in Gruntfile.js into separate task configuration files. This improves the maintainability of the Grunt build system and allows for easier future expansion. --- .eslintignore | 1 + .eslintrc | 2 +- .../babel-plugin-add-module-to-define.js | 2 +- .../components.js | 44 + .grunt/tasks/eslint.js | 64 ++ .grunt/tasks/gherkinlint.js | 89 ++ .grunt/tasks/ignorefiles.js | 59 ++ .grunt/tasks/javascript.js | 141 +++ .grunt/tasks/sass.js | 49 + .grunt/tasks/shifter.js | 155 +++ .grunt/tasks/startup.js | 48 + .grunt/tasks/style.js | 29 + .grunt/tasks/stylelint.js | 150 +++ .grunt/tasks/watch.js | 272 ++++++ Gruntfile.js | 883 +++--------------- 15 files changed, 1222 insertions(+), 766 deletions(-) rename babel-plugin-add-module-to-define.js => .grunt/babel-plugin-add-module-to-define.js (98%) rename GruntfileComponents.js => .grunt/components.js (84%) create mode 100644 .grunt/tasks/eslint.js create mode 100644 .grunt/tasks/gherkinlint.js create mode 100644 .grunt/tasks/ignorefiles.js create mode 100644 .grunt/tasks/javascript.js create mode 100644 .grunt/tasks/sass.js create mode 100644 .grunt/tasks/shifter.js create mode 100644 .grunt/tasks/startup.js create mode 100644 .grunt/tasks/style.js create mode 100644 .grunt/tasks/stylelint.js create mode 100644 .grunt/tasks/watch.js 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 84% rename from GruntfileComponents.js rename to .grunt/components.js index 74bd9de01e9..8b8291deaa4 100644 --- a/GruntfileComponents.js +++ b/.grunt/components.js @@ -140,6 +140,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. * @@ -185,4 +228,5 @@ module.exports = { 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..7124cbdf287 --- /dev/null +++ b/.grunt/tasks/sass.js @@ -0,0 +1,49 @@ +// 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/"] + } + }, + }); + + grunt.config.merge({ + watch: { + boost: { + files: [grunt.moodleEnv.inComponent ? 'scss/**/*.scss' : 'theme/boost/scss/**/*.scss'], + tasks: ['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..22f04cdd910 --- /dev/null +++ b/.grunt/tasks/stylelint.js @@ -0,0 +1,150 @@ +// 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 => { + /** + * Register any stylelint tasks. + * + * @param {Object} grunt + * @param {Array} files + * @param {String} fullRunDir + */ + const registerStyleLintTasks = () => { + const files = grunt.moodleEnv.files; + const fullRunDir = grunt.moodleEnv.fullRunDir; + const inComponent = grunt.moodleEnv.inComponent; + const inTheme = grunt.moodleEnv.inTheme; + + 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, + }, + }, + }; + }; + + let hasCss = false; + let hasScss = false; + + if (files) { + // Specific files were passed. Just set them up. + grunt.config.merge(getCssConfigForFiles(files)); + hasCss = true; + + grunt.config.merge(getScssConfigForFiles(files)); + hasScss = true; + } 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'); + + // CSS exists in: + // [component]/styles.css + // [theme_pluginname]/css + + if (inComponent) { + hasScss = false; + if (inTheme) { + const scssSrc = []; + glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path)); + + if (scssSrc.length) { + grunt.config.merge(getScssConfigForFiles(scssSrc)); + hasScss = true; + } + } + } else { + const scssSrc = []; + glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path)); + + if (scssSrc.length) { + grunt.config.merge(getScssConfigForFiles(scssSrc)); + hasScss = true; + } + } + + const cssSrc = []; + glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path)); + + if (cssSrc.length) { + grunt.config.merge(getCssConfigForFiles(cssSrc)); + hasCss = true; + } + } + + 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']); + }; + + // Register CSS tasks. + grunt.loadNpmTasks('grunt-stylelint'); + + grunt.config.merge({ + watch: { + rawcss: { + files: [ + '**/*.css', + ], + excludes: [ + '**/moodle.css', + '**/editor.css', + ], + tasks: ['rawcss'] + }, + }, + }); + + registerStyleLintTasks(); +}; 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..cdc6534ad0f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -16,147 +16,84 @@ /* 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; + /** + * 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 => { + const fs = require('fs'); + const path = require('path'); - 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'); + let cwd = fs.realpathSync(process.env.PWD || process.cwd()); - const scssSrc = []; - glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path)); - - if (scssSrc.length) { - grunt.config.merge(getScssConfigForFiles(scssSrc)); - } else { - hasScss = false; + // 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 cssSrc = []; - glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path)); - - if (cssSrc.length) { - grunt.config.merge(getCssConfigForFiles(cssSrc)); - } else { - hasCss = false; - } - } - - 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 +108,18 @@ 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(); + + 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 +139,75 @@ 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, + files, + fullRunDir, + gruntFilePath, + inAMD, + inComponent, + inTheme, + relativeCwd, + runDir, + 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']); From 32638b3a45bcf0bcd2440ff3e293458c7d1a714a Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 19 Mar 2021 07:47:02 +0800 Subject: [PATCH 2/2] MDL-68496 Grunt: Stylelint should only lint relevant component files Prior to this change the Grunt stylelint command was too greedy when determining which files hsould be linted. This change modifies the watch command to only watch relevant files and subdirectories of each component directories. This means that unrelated CSS and SCSS files are no longer watched for changes, and has the added benefit of significantly increaseing the startup speed of grunt. Without this patch applied the watch tasks were checking for matches in the node_modules, and vendor directories. --- .grunt/components.js | 11 +++ .grunt/tasks/sass.js | 9 -- .grunt/tasks/stylelint.js | 183 ++++++++++++++++++++++---------------- Gruntfile.js | 54 ++++++++++- 4 files changed, 169 insertions(+), 88 deletions(-) diff --git a/.grunt/components.js b/.grunt/components.js index 8b8291deaa4..848632db6dd 100644 --- a/.grunt/components.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. * @@ -225,6 +235,7 @@ const getOwningComponentDirectory = checkPath => { module.exports = { getAmdSrcGlobList, getComponentFromPath, + getComponentPaths, getOwningComponentDirectory, getYuiSrcGlobList, getThirdPartyLibsList, diff --git a/.grunt/tasks/sass.js b/.grunt/tasks/sass.js index 7124cbdf287..cdb8bb1fe15 100644 --- a/.grunt/tasks/sass.js +++ b/.grunt/tasks/sass.js @@ -37,13 +37,4 @@ module.exports = grunt => { } }, }); - - grunt.config.merge({ - watch: { - boost: { - files: [grunt.moodleEnv.inComponent ? 'scss/**/*.scss' : 'theme/boost/scss/**/*.scss'], - tasks: ['scss'] - }, - }, - }); }; diff --git a/.grunt/tasks/stylelint.js b/.grunt/tasks/stylelint.js index 22f04cdd910..4da9067e644 100644 --- a/.grunt/tasks/stylelint.js +++ b/.grunt/tasks/stylelint.js @@ -21,6 +21,37 @@ */ 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. * @@ -29,108 +60,106 @@ module.exports = grunt => { * @param {String} fullRunDir */ const registerStyleLintTasks = () => { - const files = grunt.moodleEnv.files; - const fullRunDir = grunt.moodleEnv.fullRunDir; - const inComponent = grunt.moodleEnv.inComponent; - const inTheme = grunt.moodleEnv.inTheme; - - 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, - }, - }, - }; - }; + 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; - if (files) { - // Specific files were passed. Just set them up. - grunt.config.merge(getCssConfigForFiles(files)); - hasCss = true; + // 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 = []; - grunt.config.merge(getScssConfigForFiles(files)); - hasScss = true; - } 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 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. - // CSS exists in: - // [component]/styles.css - // [theme_pluginname]/css + requestedFiles.forEach(changedFilePath => { + let matchesGlob; - if (inComponent) { - hasScss = false; - if (inTheme) { - const scssSrc = []; - glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path)); - - if (scssSrc.length) { - grunt.config.merge(getScssConfigForFiles(scssSrc)); - hasScss = true; - } - } - } else { - const scssSrc = []; - glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path)); - - if (scssSrc.length) { - grunt.config.merge(getScssConfigForFiles(scssSrc)); + // 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; } - } - const cssSrc = []; - glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path)); + // 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. - if (cssSrc.length) { - grunt.config.merge(getCssConfigForFiles(cssSrc)); - hasCss = true; - } + 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'); } - grunt.registerTask('scss', scssTasks); const cssTasks = []; if (hasCss) { + grunt.config.merge(getCssConfigForFiles(cssFiles)); cssTasks.push('stylelint:css'); } - grunt.registerTask('rawcss', cssTasks); - grunt.registerTask('css', ['scss', 'rawcss']); + // 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: { @@ -143,8 +172,10 @@ module.exports = grunt => { ], tasks: ['rawcss'] }, + scss: { + files: getCoreThemeMatches(), + tasks: ['scss'] + }, }, }); - - registerStyleLintTasks(); }; diff --git a/Gruntfile.js b/Gruntfile.js index cdc6534ad0f..89836af0e4f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -68,6 +68,54 @@ const setupMoodleEnvironment = grunt => { }; }; + 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 = []; + + const checkComponentDirectory = componentDirectory => { + const isTheme = componentDirectory.startsWith('theme/'); + if (isTheme) { + const scssDirectory = `${componentDirectory}/scss`; + + 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 (inComponent) { + checkComponentDirectory(componentDirectory); + } else { + ComponentList.getComponentPaths(`${gruntFilePath}/`).forEach(componentPath => { + checkComponentDirectory(componentPath); + }); + } + + return { + cssSrc, + scssSrc, + }; + }; + /** * Calculate the cwd, taking into consideration the `root` option (for Windows). * @@ -75,9 +123,6 @@ const setupMoodleEnvironment = grunt => { * @returns {String} The current directory as best we can determine */ const getCwd = grunt => { - const fs = require('fs'); - const path = require('path'); - let cwd = fs.realpathSync(process.env.PWD || process.cwd()); // Windows users can't run grunt in a subdirectory, so allow them to set @@ -113,6 +158,7 @@ const setupMoodleEnvironment = grunt => { 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')) { @@ -143,6 +189,7 @@ const setupMoodleEnvironment = grunt => { amdSrc, componentDirectory, cwd, + cssSrc, files, fullRunDir, gruntFilePath, @@ -151,6 +198,7 @@ const setupMoodleEnvironment = grunt => { inTheme, relativeCwd, runDir, + scssSrc, yuiSrc, }; };