When working with Bootstrap, one of the first tasks I do is set up LESS compilation instead of using the distributed CSS files. This allows you to directly customize Bootstrap by overriding variables and styling instead of overriding CSS styles.

This article will discuss adding that workflow to the Webpack build that is included in the react-redux SPA template included with ASP.NET Core 2.

The end result is that there will be two CSS files in wwwroot\dist that can be referenced and loaded by your site:

  • site.css
  • bootstrap.css

Converting to LESS

First, you will need to install LESS and less-loader.

npm install --save less less-loader

Now we can set up the project to use LESS.

Second, create a folder under ClientApp called less. This will be the location of our less files. Create a boostrap folder, then add three files:

  • less/site.less
  • less/bootstrap/bootstrap.less
  • less/bootstrap/variables.less

LESS folder structure

This structure does a few things:

  1. It allows us to separate our custom site styles
  2. It allows us to override bootstrap by dropping additional override files in the boostrap folder.

Next, we will configure bootstrap.less and variables.less as such:

// less/bootsrap/bootstrap.less
// import bootstrap from source
@import '../../../node_modules/bootstrap/less/bootstrap.less';

// import custom overrides
@import 'variables.less';

This file load the base LESS file from source inside of node_modules. If you look at the source file, you can see that it just includes imports for all the bootstrap modules.

We'll use a similar pattern with this file. Any component styles that you want to override, you should create a corresponding override file in our bootstrap directory and then load that override file inside of our bootstrap.less file.

We will follow this exact pattern for the variables.less file. For this file, I use the source file as a template for providing overrides. I usually comment out the source file in my override and then uncomment and change what I want.

For example:

// less/bootstrap/variables.less

// import the original file
@import '../../../node_modules/bootstrap/less/variables.less';

//
// Variables
// --------------------------------------------------


//== Colors
//
//## Gray and brand colors for use across Bootstrap.

// @gray-base: #000;
// @gray-darker: lighten(@gray-base, 13.5%); // #222
// @gray-dark: lighten(@gray-base, 20%); // #333
// @gray: lighten(@gray-base, 33.5%); // #555
// @gray-light: lighten(@gray-base, 46.7%); // #777
// @gray-lighter: lighten(@gray-base, 93.5%); // #eee

@brand-primary: #f4ad42;
@brand-success: #41f444;
@brand-info: #c0c0c0;
@brand-warning: #f1f441;
@brand-danger: #f44141;

The above snippet overrides the brand colors for use by our application. Pretty cool.

Next up is creating our site.less file. This file will contain custom components. I also load the variables file so that I can reference my bootstrap variables within styles.

// less/site.less
@import './bootstrap/variables.less';

.main-nav li .glyphicon {
    margin-right: 10px;
    color: @brand-primary;
}

/* Highlighting rules for nav menu items */
.main-nav li a.active,
.main-nav li a.active:hover,
.main-nav li a.active:focus {
    background-color: #4189C7;
    color: white;
}

Less is now all set up! We just need to integrate this into our build process.

Build LESS with Webpack

The next piece is actually including our LESS compilation into the Webpack builds.

We can start by modifying webpackge.vendor.js to remove managing the CSS file generation. You want to remove extractCss and all references to css from this file.

The webpack.config.vendor.js file looks like this after:

const path = require('path');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const merge = require('webpack-merge');

module.exports = (env) => {
    const isDevBuild = !(env && env.prod);

    const sharedConfig = {
        stats: { modules: false },
        resolve: { extensions: [ '.js' ] },
        module: {
            rules: [
                { test: /\.(png|woff|woff2|eot|ttf|svg)(\?|$)/, use: 'url-loader?limit=100000' }
            ]
        },
        entry: {
            vendor: [
                'bootstrap',
                'domain-task',
                'event-source-polyfill',
                'history',
                'react',
                'react-dom',
                'react-router-dom',
                'react-redux',
                'redux',
                'redux-thunk',
                'react-router-redux',
                'jquery'
            ],
        },
        output: {
            publicPath: '/dist/',
            filename: '[name].js',
            library: '[name]_[hash]',
        },
        plugins: [
            new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery' }), // Maps these identifiers to the jQuery package (because Bootstrap expects it to be a global variable)
            new webpack.NormalModuleReplacementPlugin(/\/iconv-loader$/, require.resolve('node-noop')), // Workaround for https://github.com/andris9/encoding/issues/16
            new webpack.DefinePlugin({
                'process.env.NODE_ENV': isDevBuild ? '"development"' : '"production"'
            })
        ]
    };

    const clientBundleConfig = merge(sharedConfig, {
        output: { path: path.join(__dirname, 'wwwroot', 'dist') },
        plugins: [
            new webpack.DllPlugin({
                path: path.join(__dirname, 'wwwroot', 'dist', '[name]-manifest.json'),
                name: '[name]_[hash]'
            })
        ].concat(isDevBuild ? [] : [
            new webpack.optimize.UglifyJsPlugin()
        ])
    });

    const serverBundleConfig = merge(sharedConfig, {
        target: 'node',
        resolve: { mainFields: ['main'] },
        output: {
            path: path.join(__dirname, 'ClientApp', 'dist'),
            libraryTarget: 'commonjs2',
        },
        entry: { vendor: ['aspnet-prerendering', 'react-dom/server'] },
        plugins: [
            new webpack.DllPlugin({
                path: path.join(__dirname, 'ClientApp', 'dist', '[name]-manifest.json'),
                name: '[name]_[hash]'
            })
        ]
    });

    return [clientBundleConfig, serverBundleConfig];
};

Now we'll want to add compilation to webpack.config.js since Bootstrap is now part of our application. We do this by removing references to CSS and adding extractSiteLess and extractBootstrapLess in its place. These files will output to site.css and bootstrap.css respectively.

const path = require('path');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const CheckerPlugin = require('awesome-typescript-loader').CheckerPlugin;
const merge = require('webpack-merge');

module.exports = (env) => {
    const isDevBuild = !(env && env.prod);
    
    // replace extractCSS with these two extractors
    const extractSiteLess = new ExtractTextPlugin('site.css');
    const extractBootstrapLess = new ExtractTextPlugin('bootstrap.css');

    // Configuration in common to both client-side and server-side bundles
    const sharedConfig = () => ({
        stats: { modules: false },
        resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
        output: {
            filename: '[name].js',
            publicPath: '/dist/' // Webpack dev middleware, if enabled, handles requests for this URL prefix
        },
        module: {
            rules: [
                { test: /\.tsx?$/, include: /ClientApp/, use: 'awesome-typescript-loader?silent=true' }
            ]
        },
        plugins: [new CheckerPlugin()]
    });

    // Configuration for client-side bundle suitable for running in browsers
    const clientBundleOutputDir = './wwwroot/dist';
    const clientBundleConfig = merge(sharedConfig(), {
        entry: { 'main-client': './ClientApp/boot-client.tsx' },
        module: {
            rules: [

            // use this rule to compile site.css
            // it limits its input to site.less
            // don't forget to include the less-loader
            { 
                test: /site\.less$/, 
                use: extractSiteLess.extract({
                    use: [
                        { loader: isDevBuild ? 'css-loader' : 'css-loader?minimize' }, 
                        { loader: 'less-loader' }
                    ]
                }),
            }, 

            // use this rule to compile bootstrap.css
            // it limits its input to bootstrap.less
            // don't forget to include the less-loader
            { 
                test: /bootstrap\.less$/, 
                use: extractBootstrapLess.extract({
                    use: [
                        { loader: isDevBuild ? 'css-loader' : 'css-loader?minimize' }, 
                        { loader: 'less-loader' }
                    ]
                }),
            }, 
            { 
                test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|eot|ttf)$/, 
                use: 'url-loader?limit=25000' 
            }]
        },
        output: { path: path.join(__dirname, clientBundleOutputDir) },
        plugins: [

            // include the less plugins 
            extractSiteLess,
            extractBootstrapLess,
            new webpack.DllReferencePlugin({
                context: __dirname,
                manifest: require('./wwwroot/dist/vendor-manifest.json')
            }),
        ].concat(isDevBuild ? [
            // Plugins that apply in development builds only
            new webpack.SourceMapDevToolPlugin({
                filename: '[file].map', // Remove this line if you prefer inline source maps
                moduleFilenameTemplate: path.relative(clientBundleOutputDir, '[resourcePath]') // Point sourcemap entries to the original file locations on disk
            })
        ] : [
            // Plugins that apply in production builds only
            new webpack.optimize.UglifyJsPlugin()
        ])
    });

    // Configuration for server-side (prerendering) bundle suitable for running in Node
    const serverBundleConfig = merge(sharedConfig(), {
        resolve: { mainFields: ['main'] },
        entry: { 'main-server': './ClientApp/boot-server.tsx' },
        plugins: [
            new webpack.DllReferencePlugin({
                context: __dirname,
                manifest: require('./ClientApp/dist/vendor-manifest.json'),
                sourceType: 'commonjs2',
                name: './vendor'
            })
        ],
        output: {
            libraryTarget: 'commonjs',
            path: path.join(__dirname, './ClientApp/dist')
        },
        target: 'node',
        devtool: 'inline-source-map'
    });

    return [clientBundleConfig, serverBundleConfig];
};

Lastly, to bootstrap this process (no pun intended), modify boot-client.tsx to include both site.less and boootstrap.less

import './less/site.less';
import './less/bootstrap/bootstrap.less';

Finalizing Things

You should be able to test your builds from the command line by running:

node node_modules/webpack/bin/webpack.js --config webpack.config.js
 node node_modules/webpack/bin/webpack.js --config webpack.config.vendor.js

Both of those should compile without issue if you set things up correctly.

Final cleanup... make sure you modify _Layout to include the bootstrap.css file.

You can also delete the ClientApp/css folder since it is no longer in use.

Happy coding.