Skip to content

Instantly share code, notes, and snippets.

@NickCis
Last active October 26, 2021 14:56
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save NickCis/a4b2e4dc7a2466c13ce54240d4ab5d04 to your computer and use it in GitHub Desktop.
Save NickCis/a4b2e4dc7a2466c13ce54240d4ab5d04 to your computer and use it in GitHub Desktop.
Webpack - themes processing (multi import)

Processing a file with multiple themes

As webpack doesn't provide a way to run several loaders for the same file [1], in order to produce many themed css style files from the same compilation, some hack has to be used.

This was tested using:

  • extract-text-webpack-plugin: 3.0.2
  • webpack: 3.10.0
  • node-sass: 4.7.2
  • sass-loader: 6.0.6
  • style-loader: 0.19.1
  • css-loader: 0.28.9

The idea behind:

  1. Use SASS's variables in order to provide differences between themes (each theme has got it's own varaibles definition file)
  2. For each theme, generate a valid ExtractPlugin configuration (putting the sass loader with it's themed variable definition)
  3. In order to process a file several times, we'll use multi-loader's approach (ie.: we'll duplicate imports inlining all the loader configuration)
  4. Inlinning loader configuration can't be done if the configuration is too complex [2], luckily webpack stores configuration in the compilation's RuleSet under the property references. These references use an id which is called ident. We'll use the AddIdentPlugin in order to add the configuration to the RuleSet.

Problems

  • Multiple importing leads to a unused anonymous object in the js file
  • Breaking style-loader
  • Hackish use of webpack's internals

Webpack config example

// webpack.config.js
// ... imports
const AddIdentPlugin = require('./AddIdentPlugin');

/** Reads variables particular to the theme.

  @param {String} theme - name of the theme
  @return {String} Theme's variables
*/
const readThemedData = theme => { ... };
                                 
/** Builds webpack's style configuration
  @param {String} theme - name of the theme
  @return {Object} theme configuration - {
      {WebpackPlugin} plugin: ExtractPlugin instance
      {WebpackLoader} loader: ExtractPlugin loader
    }
*/
const buildTheme = theme => {
  const plugin = new ExtractTextPlugin({
    filename: `styles.${theme}.[name].[contenthash].css`,
    allChunks: true
  });
  
  const loader = plugin.extract({
    fallback: 'style-loader',
    use: [
      {
        loader: 'css-loader',
        options: {
          modules: true,
          importLoaders: 2,
        }
      },
      'postcss-loader',
      {
        loader: 'sass-loader',
        options: {
          data: readThemedData(theme)
        }
      }
    ]
  });

  return {
    plugin,
    loader
  };
};

const themes = ['first-theme', 'another-nice-theme'];
const themesConf = themes.map(theme => buildTheme(theme));
module.exports = {
  // I'll ommit not relevant webpack configuration
  module: {
    rules: [
      // ...
      {
        test: /\.scss$/,
        loader: path.resolve('./multi-loader.js'),
        options: {
          loaders: themesConf.map(t => t.loader)
        }
      }      
    ]
  },
  plugins: [
    // ...
    ...themesConf.map(t => t.plugin),
    new AddIdentPlugin({ loaders: themesConf.reduce((all, l) => all.concat(l.loader), []) })
  ]
};

[1] Webpack doesn't allow to run different loaders pahts for the same file, because in webpack's point of view, this doesnt' have sense. Loaders are used to transform the specified file's code and assined to the variable, if several loader paths are executed what will be the valued assigned to the variable?

[2] With complex configuration, i'm saying not only to not serializable conf (ie: object that i cannot transform with JSON.stringify/parse) but also complex and long objects.

'use strict';
let refCounter = 0;
/** This plugin adds the options of the provided loaders to Webpack's RuleSet reference.
* This allows you to refer to this config by an `ident` name and avoid the options serialization.
*
* The simpler use case is forcing a loader by using a require query string, if the needed configuration has
* functions or not serializable objects, you'll have to use this `ident` plugin.
*
* If the provided loaders options do not have an `ident` property, one is generated.
* ** THIS OPTIONS OBJECT ARE MODIFIED, THE PLUGIN ADDS THE `ident` PROPERTY!! **
*/
class AddIdentPlugin {
/**
* @params <Object> options - {
* loaders: <Loader[]> An array of Loaders, the `option` property is used (and could be modified)
* }
*/
constructor(options) {
this.loaders = options.loaders;
}
apply(compiler) {
compiler.plugin('compilation', (compilation, params) => {
const ruleSet = params.normalModuleFactory.ruleSet;
this.loaders.forEach(loader => {
if (!loader.options) {
return;
}
if (!loader.ident) {
loader.ident = `AddIdentPlugin-ref-${refCounter++}`;
}
if (!ruleSet.references[loader.ident]) {
ruleSet.references[loader.ident] = loader.options;
}
});
});
}
}
module.exports = AddIdentPlugin;
'use strict';
const loaderUtils = require('loader-utils');
/** Idea ~taken~ almost robed from [`multi-loader`](https://github.com/webpack-contrib/multi-loader).
Reading how webpack's [loader-runner](https://github.com/webpack/loader-runner) works algo helps.
*/
module.exports = () => {};
module.exports.pitch = function MultiLoaderPitch(request) {
this.cacheable();
const options = loaderUtils.getOptions(this);
const requires = options.loaders.map(loaders => {
let string = '-!';
(Array.isArray(loaders) ? loaders : [loaders]).forEach(loader => {
if (typeof loader === 'string' || loader instanceof String) {
string += `${loader}!`;
}
else if (loader.ident) {
string += `${loader.loader}??${loader.ident}!`;
}
else if (loader.options) {
string += `${loader.loader}?${JSON.stringify(loader.options)}!`;
}
else {
string += `${loader.loader}!`;
}
});
string += request;
return `require(${JSON.stringify(string)});`;
});
const last = requires.pop();
return `${requires.join('\n')}\nmodule.exports = ${last};`;
};
@depoulo
Copy link

depoulo commented May 2, 2018

Many thanks, you really saved my day!
I can't tell why (my webpack config looks pretty similar, just that I'm using a custom less plugin for applying the theme differences), but I had to modify your AddIdentPlugin to also work with arrays, like this:

@@ -20,23 +20,17 @@
     this.loaders = options.loaders
   }

+  addIdent(loader, ruleSet) {
+    if (Array.isArray(loader)) return loader.forEach(l => this.addIdent(l, ruleSet))
+    if (!loader.options) return
+    if (!loader.ident) loader.ident = `AddIdentPlugin-ref-${refCounter++}`
+    if (!ruleSet.references[loader.ident]) ruleSet.references[loader.ident] = loader.options
+  }
+
   apply(compiler) {
     compiler.plugin('compilation', (compilation, params) => {
       const ruleSet = params.normalModuleFactory.ruleSet
-
-      this.loaders.forEach(loader => {
-        if (!loader.options) {
-          return
-        }
-
-        if (!loader.ident) {
-          loader.ident = `AddIdentPlugin-ref-${refCounter++}`
-        }
-
-        if (!ruleSet.references[loader.ident]) {
-          ruleSet.references[loader.ident] = loader.options
-        }
-      })
+      this.loaders.forEach(loader => this.addIdent(loader, ruleSet))
     })
   }
 }

@fedevegili
Copy link

Thank you! That was really helpful

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment