diff --git a/index.js b/index.js index f0325126..0254ac12 100644 --- a/index.js +++ b/index.js @@ -1100,6 +1100,32 @@ class Encore { return this; } + /** + * Configure Webpack loaders rules (`module.rules`). + * This is a low-level function, be careful when using it. + * + * https://webpack.js.org/concepts/loaders/#configuration + * + * For example, if you are using Vue and ESLint loader, + * this is how you can configure ESLint to lint Vue files: + * + * Encore + * .enableEslintLoader() + * .enableVueLoader() + * .configureLoaderRule('eslint', (loaderRule) => { + * loaderRule.test = /\.(jsx?|vue)/; + * }); + * + * @param {string} name + * @param {function} callback + * @return {Encore} + */ + configureLoaderRule(name, callback) { + webpackConfig.configureLoaderRule(name, callback); + + return this; + } + /** * If enabled, the output directory is emptied between each build (to remove old files). * diff --git a/lib/WebpackConfig.js b/lib/WebpackConfig.js index 6c0d9f5f..1dfe85ba 100644 --- a/lib/WebpackConfig.js +++ b/lib/WebpackConfig.js @@ -100,6 +100,19 @@ class WebpackConfig { this.eslintLoaderOptionsCallback = () => {}; this.tsConfigurationCallback = () => {}; this.handlebarsConfigurationCallback = () => {}; + this.loaderConfigurationCallbacks = { + javascript: () => {}, + css: () => {}, + images: () => {}, + fonts: () => {}, + sass: () => {}, + less: () => {}, + stylus: () => {}, + vue: () => {}, + eslint: () => {}, + typescript: () => {}, + handlebars: () => {}, + }; // Plugins options this.cleanWebpackPluginPaths = ['**/*']; @@ -716,6 +729,31 @@ class WebpackConfig { }); } + configureLoaderRule(name, callback) { + logger.warning('Be careful when using Encore.configureLoaderRule(), this is a low-level method that can potentially break Encore and Webpack when not used carefully.'); + + // Key: alias, Value: existing loader in `this.loaderConfigurationCallbacks` + const aliases = { + js: 'javascript', + ts: 'typescript', + scss: 'sass', + }; + + if (name in aliases) { + name = aliases[name]; + } + + if (!(name in this.loaderConfigurationCallbacks)) { + throw new Error(`Loader "${name}" is not configurable. Valid loaders are "${Object.keys(this.loaderConfigurationCallbacks).join('", "')}" and the aliases "${Object.keys(aliases).join('", "')}".`); + } + + if (typeof callback !== 'function') { + throw new Error('Argument 2 to configureLoaderRule() must be a callback function.'); + } + + this.loaderConfigurationCallbacks[name] = callback; + } + useDevServer() { return this.runtimeConfig.useDevServer; } diff --git a/lib/config-generator.js b/lib/config-generator.js index 90b112af..29555454 100644 --- a/lib/config-generator.js +++ b/lib/config-generator.js @@ -221,14 +221,18 @@ class ConfigGenerator { } buildRulesConfig() { + const applyRuleConfigurationCallback = (name, defaultRules) => { + return applyOptionsCallback(this.webpackConfig.loaderConfigurationCallbacks[name], defaultRules); + }; + let rules = [ - { + applyRuleConfigurationCallback('javascript', { // match .js and .jsx test: /\.jsx?$/, exclude: this.webpackConfig.babelOptions.exclude, use: babelLoaderUtil.getLoaders(this.webpackConfig) - }, - { + }), + applyRuleConfigurationCallback('css', { test: /\.css$/, oneOf: [ { @@ -245,7 +249,7 @@ class ConfigGenerator { ) } ] - } + }) ]; if (this.webpackConfig.useImagesLoader) { @@ -269,11 +273,11 @@ class ConfigGenerator { Object.assign(loaderOptions, this.webpackConfig.urlLoaderOptions.images); } - rules.push({ + rules.push(applyRuleConfigurationCallback('images', { test: /\.(png|jpg|jpeg|gif|ico|svg|webp)$/, loader: loaderName, options: loaderOptions - }); + })); } if (this.webpackConfig.useFontsLoader) { @@ -297,15 +301,15 @@ class ConfigGenerator { Object.assign(loaderOptions, this.webpackConfig.urlLoaderOptions.fonts); } - rules.push({ + rules.push(applyRuleConfigurationCallback('fonts', { test: /\.(woff|woff2|ttf|eot|otf)$/, loader: loaderName, options: loaderOptions - }); + })); } if (this.webpackConfig.useSassLoader) { - rules.push({ + rules.push(applyRuleConfigurationCallback('sass', { test: /\.s[ac]ss$/, oneOf: [ { @@ -316,11 +320,11 @@ class ConfigGenerator { use: cssExtractLoaderUtil.prependLoaders(this.webpackConfig, sassLoaderUtil.getLoaders(this.webpackConfig)) } ] - }); + })); } if (this.webpackConfig.useLessLoader) { - rules.push({ + rules.push(applyRuleConfigurationCallback('less', { test: /\.less/, oneOf: [ { @@ -331,11 +335,11 @@ class ConfigGenerator { use: cssExtractLoaderUtil.prependLoaders(this.webpackConfig, lessLoaderUtil.getLoaders(this.webpackConfig)) } ] - }); + })); } if (this.webpackConfig.useStylusLoader) { - rules.push({ + rules.push(applyRuleConfigurationCallback('stylus', { test: /\.styl/, oneOf: [ { @@ -346,39 +350,39 @@ class ConfigGenerator { use: cssExtractLoaderUtil.prependLoaders(this.webpackConfig, stylusLoaderUtil.getLoaders(this.webpackConfig)) } ] - }); + })); } if (this.webpackConfig.useVueLoader) { - rules.push({ + rules.push(applyRuleConfigurationCallback('vue', { test: /\.vue$/, use: vueLoaderUtil.getLoaders(this.webpackConfig) - }); + })); } if (this.webpackConfig.useEslintLoader) { - rules.push({ + rules.push(applyRuleConfigurationCallback('eslint', { test: /\.jsx?$/, loader: 'eslint-loader', exclude: /node_modules/, enforce: 'pre', options: eslintLoaderUtil.getOptions(this.webpackConfig) - }); + })); } if (this.webpackConfig.useTypeScriptLoader) { - rules.push({ + rules.push(applyRuleConfigurationCallback('typescript', { test: /\.tsx?$/, exclude: /node_modules/, use: tsLoaderUtil.getLoaders(this.webpackConfig) - }); + })); } if (this.webpackConfig.useHandlebarsLoader) { - rules.push({ + rules.push(applyRuleConfigurationCallback('handlebars', { test: /\.(handlebars|hbs)$/, use: handlebarsLoaderUtil.getLoaders(this.webpackConfig) - }); + })); } this.webpackConfig.loaders.forEach((loader) => { diff --git a/test/WebpackConfig.js b/test/WebpackConfig.js index 6d2f1ffc..17eb8f89 100644 --- a/test/WebpackConfig.js +++ b/test/WebpackConfig.js @@ -1144,4 +1144,36 @@ describe('WebpackConfig object', () => { }).to.throw('Argument 1 to configureWatchOptions() must be a callback function.'); }); }); + + describe('configureLoaderRule()', () => { + it('works properly', () => { + const config = createConfig(); + const callback = (loader) => {}; + + expect(config.loaderConfigurationCallbacks['eslint']).to.not.equal(callback); + + config.configureLoaderRule('eslint', callback); + expect(config.loaderConfigurationCallbacks['eslint']).to.equal(callback); + }); + + it('Call method with a not supported loader', () => { + const config = createConfig(); + + expect(() => { + config.configureLoaderRule('reason'); + }).to.throw('Loader "reason" is not configurable. Valid loaders are "javascript", "css", "images", "fonts", "sass", "less", "stylus", "vue", "eslint", "typescript", "handlebars" and the aliases "js", "ts", "scss".'); + }); + + it('Call method with not a valid callback', () => { + const config = createConfig(); + + expect(() => { + config.configureLoaderRule('eslint'); + }).to.throw('Argument 2 to configureLoaderRule() must be a callback function.'); + + expect(() => { + config.configureLoaderRule('eslint', {}); + }).to.throw('Argument 2 to configureLoaderRule() must be a callback function.'); + }); + }); }); diff --git a/test/config-generator.js b/test/config-generator.js index 214d1a14..110c6864 100644 --- a/test/config-generator.js +++ b/test/config-generator.js @@ -1014,4 +1014,207 @@ describe('The config-generator function', () => { }); }); }); + + describe('Test configureLoaderRule()', () => { + let config; + + beforeEach(() => { + config = createConfig(); + config.outputPath = '/tmp/public/build'; + config.setPublicPath('/'); + config.enableSingleRuntimeChunk(); + }); + + it('configure rule for "javascript"', () => { + config.configureLoaderRule('javascript', (loaderRule) => { + loaderRule.test = /\.m?js$/; + loaderRule.use[0].options.fooBar = 'fooBar'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.m?js$/, webpackConfig.module.rules); + + expect('file.js').to.match(rule.test); + expect('file.mjs').to.match(rule.test); + expect(rule.use[0].options.fooBar).to.equal('fooBar'); + }); + + it('configure rule for the alias "js"', () => { + config.configureLoaderRule('js', (loaderRule) => { + loaderRule.test = /\.m?js$/; + loaderRule.use[0].options.fooBar = 'fooBar'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.m?js$/, webpackConfig.module.rules); + + expect('file.js').to.match(rule.test); + expect('file.mjs').to.match(rule.test); + expect(rule.use[0].options.fooBar).to.equal('fooBar'); + }); + + it('configure rule for "css"', () => { + config.configureLoaderRule('css', (loaderRule) => { + loaderRule.camelCase = true; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.css$/, webpackConfig.module.rules); + + expect(rule.camelCase).to.be.true; + }); + + it('configure rule for "images"', () => { + config.configureLoaderRule('images', (loaderRule) => { + loaderRule.options.name = 'dirname-images/[hash:42].[ext]'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.(png|jpg|jpeg|gif|ico|svg|webp)$/, webpackConfig.module.rules); + + expect(rule.options.name).to.equal('dirname-images/[hash:42].[ext]'); + }); + + it('configure rule for "fonts"', () => { + config.configureLoaderRule('fonts', (loader) => { + loader.options.name = 'dirname-fonts/[hash:42].[ext]'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.(woff|woff2|ttf|eot|otf)$/, webpackConfig.module.rules); + + expect(rule.options.name).to.equal('dirname-fonts/[hash:42].[ext]'); + }); + + it('configure rule for "sass"', () => { + config.enableSassLoader(); + config.configureLoaderRule('sass', (loaderRule) => { + loaderRule.oneOf[1].use[2].options.fooBar = 'fooBar'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.s[ac]ss$/, webpackConfig.module.rules); + + expect(rule.oneOf[1].use[2].options.fooBar).to.equal('fooBar'); + }); + + it('configure rule for the alias "scss"', () => { + config.enableSassLoader(); + config.configureLoaderRule('scss', (loaderRule) => { + loaderRule.oneOf[1].use[2].options.fooBar = 'fooBar'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.s[ac]ss$/, webpackConfig.module.rules); + + expect(rule.oneOf[1].use[2].options.fooBar).to.equal('fooBar'); + }); + + it('configure rule for "less"', () => { + config.enableLessLoader((options) => { + options.optionA = 'optionA'; + }); + config.configureLoaderRule('less', (loaderRule) => { + loaderRule.oneOf[1].use[2].options.optionB = 'optionB'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.less/, webpackConfig.module.rules); + + expect(rule.oneOf[1].use[2].options.optionA).to.equal('optionA'); + expect(rule.oneOf[1].use[2].options.optionB).to.equal('optionB'); + }); + + it('configure rule for "stylus"', () => { + config.enableStylusLoader((options) => { + options.optionA = 'optionA'; + }); + config.configureLoaderRule('stylus', (loaderRule) => { + loaderRule.oneOf[1].use[2].options.optionB = 'optionB'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.styl/, webpackConfig.module.rules); + + expect(rule.oneOf[1].use[2].options.optionA).to.equal('optionA'); + expect(rule.oneOf[1].use[2].options.optionB).to.equal('optionB'); + }); + + it('configure rule for "vue"', () => { + config.enableVueLoader((options) => { + options.shadowMode = true; + }); + config.configureLoaderRule('vue', (loaderRule) => { + loaderRule.use[0].options.prettify = false; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.vue$/, webpackConfig.module.rules); + + expect(rule.use[0].options.shadowMode).to.be.true; + expect(rule.use[0].options.prettify).to.be.false; + }); + + it('configure rule for "eslint"', () => { + config.enableEslintLoader((options) => { + options.extends = 'airbnb'; + }); + config.configureLoaderRule('eslint', (loaderRule) => { + loaderRule.test = /\.(jsx?|vue)/; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.(jsx?|vue)/, webpackConfig.module.rules); + + expect(rule.options.extends).to.equal('airbnb'); + expect('file.js').to.match(rule.test); + expect('file.jsx').to.match(rule.test); + expect('file.vue').to.match(rule.test); + }); + + it('configure rule for "typescript" and "ts"', () => { + config.enableTypeScriptLoader((options) => { + options.silent = true; + }); + config.configureLoaderRule('typescript', (loaderRule) => { + loaderRule.use[1].options.happyPackMode = true; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.tsx?$/, webpackConfig.module.rules); + + expect(rule.use[1].options.silent).to.be.true; + expect(rule.use[1].options.happyPackMode).to.be.true; + }); + + it('configure rule for the alias "ts"', () => { + config.enableTypeScriptLoader((options) => { + options.silent = true; + }); + config.configureLoaderRule('ts', (loaderRule) => { + loaderRule.use[1].options.happyPackMode = true; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.tsx?$/, webpackConfig.module.rules); + + expect(rule.use[1].options.silent).to.be.true; + expect(rule.use[1].options.happyPackMode).to.be.true; + }); + + it('configure rule for "handlebars"', () => { + config.enableHandlebarsLoader((options) => { + options.debug = true; + }); + config.configureLoaderRule('handlebars', (loaderRule) => { + loaderRule.use[0].options.fooBar = 'fooBar'; + }); + + const webpackConfig = configGenerator(config); + const rule = findRule(/\.(handlebars|hbs)$/, webpackConfig.module.rules); + + expect(rule.use[0].options.debug).to.be.true; + expect(rule.use[0].options.fooBar).to.be.equal('fooBar'); + }); + }); });