11
votes

There are a few SO posts about style-loader and css-loader, but despite this I have not been able to find a solution to my problem.

In short summary, when I @import css files in other css files, and the imported css contains url()s with relative paths, the paths are not resolved correctly.

Basically, the error message shows that Webpack ends up thinking the url() paths in the imported css are relative to src (main entry point), rather than being relative to the css file it it is imported into:

// css-one.scss
@import "./assets/open-iconic-master/font/css/open-iconic-bootstrap.css";

// open-iconic-bootstrap.css
@font-face {
    src: url('../fonts/open-iconic.eot');
}

Error:

ERROR in ./src/main.scss (./node_modules/css-loader??ref--5-1!./node_modules/postcss-loader/src??ref--5-2!./node_modules/sass-loader/lib/loader.js??ref--5-3!./src/main.scss)

Module not found: Error: Can't resolve '../fonts/open-iconic.eot' in 'C:\Users\...\src' @ ./src/main.scss (./node_modules/css-loader??ref--5-1!./node_modules/postcss-loader/src??ref--5-2!./node_modules/sass-loader/lib/loader.js??ref--5-3!./src/main.scss) 7:106-141 7:172-207 @ ./src/main.scss @ ./src/index.js

What I Have Tried:

My Webpack Config File (loaders are at the bottom):

const path = require('path');
const webpack = require('webpack'); // for webpack built-in plugins
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
// const WriteFilePlugin = require('write-file-webpack-plugin');
// const ManifestPlugin = require('webpack-manifest-plugin');
// const InlineManifestWebpackPlugin = require('inline-manifest-webpack-plugin');

// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

const PATHS = {
  // when using __dirname, resolve and join gives same result,
  // because __dirname is absolute path to directory of this file.
  // OK to use no slashes,
  // both resolve and join adds platform-specific separators by default
  src: path.resolve(__dirname, 'src'),
  dist: path.resolve(__dirname, 'dist'),
  build: path.resolve(__dirname, 'build'),
  test: path.resolve(__dirname, 'test')
};

const NAMES = {
  // JS FILES
  index: 'index',
  print: 'print',
  // Chrome Extension Development
  popup: 'popup',
  options: 'options',
  background: 'background',
  contentScript: 'contentScript',

  // FOLDERS
  assets: 'assets',
  utilities: 'utilities',
  images: 'images',
  fonts: 'fonts',
  include: 'include'
};

const FILE_PATHS = {
  // JS
  indexJs: `${path.join(PATHS.src, NAMES.index)}.js`,
  printJs: `${path.join(PATHS.src, NAMES.print)}.js`,
  // Chrome Extension Development
  popupJs: `${path.join(PATHS.src, NAMES.popup)}.js`,
  optionsJs: `${path.join(PATHS.src, NAMES.options)}.js`,
  backgroundJs: `${path.join(PATHS.src, NAMES.background)}.js`,
  contentScriptJs: `${path.join(
    PATHS.src,
    NAMES.include,
    NAMES.contentScript
  )}.js`,

  // HTML
  indexHtml: `${path.join(PATHS.src, NAMES.index)}.html`,
  printHtml: `${path.join(PATHS.src, NAMES.print)}.html`,
  // Chrome Extension Development
  popupHtml: `${path.join(PATHS.src, NAMES.popup)}.html`,
  optionsHtml: `${path.join(PATHS.src, NAMES.options)}.html`,
  backgroundHtml: `${path.join(PATHS.src, NAMES.background)}.html`
};

// Third-party (vendor) libraries to include
// const VENDORS = ['react', 'bootstrap', 'lodash', 'jQuery']; // Relative paths to node_modules

// Note: These are relative
const ASSETS = {
  images: path.join(NAMES.assets, NAMES.images),
  fonts: path.join(NAMES.assets, NAMES.fonts)
};

// CleanWebpackPlugin config
const pathsToClean = [PATHS.dist, PATHS.build];
const cleanOptions = {
  root: __dirname,
  exclude: ['shared.js'],
  verbose: true,
  dry: false
};

// CopyWebpackPlugin config
const copyPattern = [
  // {
  // from: NAMES.assets,
  // to: NAMES.assets
  // },
  // {
  // from: path.join(NAMES.include, 'contentScript.css')
  // },
  // {
  // from: 'manifest.json',
  // transform(content, copyPath) {
  // // generates the manifest file using the package.json informations
  // return Buffer.from(
  // JSON.stringify({
  // ...JSON.parse(content.toString())
  // // description: env.npm_package_description,
  // // version: env.npm_package_version
  // })
  // );
  // }
  // }
];
const copyOptions = {
  // ignore: ['*.js'],
  context: PATHS.src
};

module.exports = (env = {}) => {
  // webpack injects env variable, into webpack config.
  // perfect to check for production.
  // remember to specify --env.production in command
  // (if in production mode).
  const isProduction = env.production === true;

  return {
    entry: {
      index: FILE_PATHS.indexJs

      // Chrome Extension Development
      // popup: FILE_PATHS.popupJs,
      // contentScript: FILE_PATHS.contentScriptJs
      // options: FILE_PATHS.optionsJs,
      // background: FILE_PATHS.backgroundJs,

      // vendor: VENDORS
    },
    mode: isProduction ? 'production' : 'development',
    devtool: isProduction ? 'source-map' : 'inline-source-map',
    optimization: {
      splitChunks: {
        chunks: 'all'
      }
    },
    output: {
      filename: isProduction ? '[name].[chunkhash:8].js' : '[name].js',
      // chunkFilename determine name of non-entry chunk files,
      // for example dynamic imports in the app
      chunkFilename: isProduction ? '[name].[chunkhash:8].js' : '[name].js',
      path: PATHS.dist
    },
    plugins: [
      // new webpack.SourceMapDevToolPlugin({
      // filename: '[file].map',
      // exclude: ['vendor', 'runtime']
      // }),
      new webpack.DefinePlugin({
        // specifies environment variable for dependencies.
        // does not apply to browser runtime environment
        // (process.env is provisioned by Node)
        'process.env.NODE_ENV': isProduction ?
          JSON.stringify('production') :
          JSON.stringify('development')
      }),
      // new BundleAnalyzerPlugin(),
      new CleanWebpackPlugin(pathsToClean, cleanOptions),
      new MiniCssExtractPlugin({
        // Options similar to the same options in webpackOptions.output
        // both options are optional
        // does not work with Hot Module Replacement (HMR)
        // allows HMR in development (will only use this plugin in production)
        filename: isProduction ? '[name].[contenthash].css' : '[name].css',
        chunkFilename: isProduction ? '[id].[contenthash].css' : '[id].css'
      }),
      new webpack.HashedModuleIdsPlugin(),
      isProduction ?
      new UglifyJSPlugin({
        cache: true,
        parallel: true,
        sourceMap: true // set to true if you want JS source maps
      }) :
      () => {},
      new CopyWebpackPlugin(copyPattern, copyOptions),
      // new WriteFilePlugin(),
      new HtmlWebpackPlugin({
        template: FILE_PATHS.indexHtml,
        filename: `${NAMES.index}.html`
      })
      // new HtmlWebpackPlugin({
      // template: FILE_PATHS.popupHtml,
      // filename: `${NAMES.popup}.html`,
      // excludeChunks: [NAMES.contentScript]
      // In dev mode, chunks excluded vendor chunk (which holds CSS).
      // Above check fixes it.
      // }),
      // new HtmlWebpackPlugin({
      // filename: `${NAMES.contentScript}.html`,
      // excludeChunks: [NAMES.popup, 'runtime'] // Runtime only needed in one HTML
      // }),
      // new HtmlWebpackPlugin({
      // template: FILE_PATHS.optionsHtml,
      // filename: `${NAMES.options}.html`,
      // chunks: isProduction ? [NAMES.options] : ''
      // }),
      // new HtmlWebpackPlugin({
      // template: FILE_PATHS.backgroundHtml,
      // filename: `${NAMES.background}.html`,
      // chunks: isProduction ? [NAMES.background] : ''
      // }),
      // no need for CSS minimization here <-- Done by PostCSS (cssnano)
      // new InlineManifestWebpackPlugin(),
      // new ManifestPlugin({fileName: 'webpack-manifest.json'}),
    ],
    module: {
      rules: [{
          test: /\.js$/,
          exclude: /(node_modules|bower_components)/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env']
            }
          }
        },
        {
          test: /\.s?[ac]ss$/,
          exclude: /node_modules/,
          use: [
            isProduction ?
            MiniCssExtractPlugin.loader :
            {
              // creates style nodes from JS strings
              loader: 'style-loader',
              options: {
                sourceMap: true,
                convertToAbsoluteUrls: true
              }
            },
            {
              // CSS to CommonJS (resolves CSS imports into exported CSS strings)
              loader: 'css-loader',
              options: {
                sourceMap: true,
                importLoaders: 2
              }
            },
            {
              loader: 'postcss-loader',
              options: {
                config: {
                  ctx: {
                    cssnext: {},
                    cssnano: {},
                    autoprefixer: {}
                  }
                },
                sourceMap: true
              }
            },
            {
              // compiles Sass to CSS
              loader: 'sass-loader',
              options: {
                sourceMap: true
              }
            }
          ]
        },
        {
          test: /\.(png|svg|jpg|gif)$/,
          use: [{
            loader: 'file-loader',
            options: {
              name: '[name].[hash:4].[ext]',
              outputPath: ASSETS.images
            }
          }]
        },
        {
          test: /\.(woff|woff2|eot|ttf|otf)$/,
          use: [{
            loader: 'file-loader',
            options: {
              name: '[name].[hash:4].[ext]',
              outputPath: ASSETS.fonts
            }
          }]
        },
        {
          test: /\.(csv|tsv)$/,
          use: ['csv-loader']
        },
        {
          test: /\.xml$/,
          use: ['xml-loader']
        },
        {
          test: /\.(html)$/,
          use: {
            loader: 'html-loader',
            options: {
              interpolate: 'require',
              minimize: true
            }
          }
        }
        // {
        // test: /\.tsx?$/,
        // exclude: /(node_modules|bower_components)/,
        // use: 'ts-loader'
        // }
      ]
    },
    devServer: {
      // contentBase: path.join(__dirname, 'dist'),
      contentBase: PATHS.dist,
      compress: false,
      port: 8080,
      open: false
    }
  };
};
3

3 Answers

11
votes

it took me around 5 days of work to understand how this webpack mess works. I have to be honest I can say that this is one of those things that I really do not understand why they are "defacto" tools of the moment. I can't understand how difficult it can be just to make the config files work as it should, in gulp took me 1 hour to do the same.

My problem was that all the url() rules (including fonts and images) were being loaded by css-loader as [object Module], and they where exported by file-loader but never loaded, so if I added ?url=false to the css-loader it never copied the files and export them. I have to say this was a totally PITA, but I got it working, and I hope it works for somebody else in the world, this was made with webpack 4.

const webpack = require("webpack");
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ImageminPlugin = require('imagemin-webpack-plugin').default;
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
    entry: "./src/index.js",
    mode: "development",
    module: {
        rules: [
        {
            test: /\.(js|jsx)$/,
            exclude: /(node_modules|bower_components)/,
            loader: "babel-loader",
            options: { presets: ["@babel/env"] }
        },
        {
            test: /\.(gif|png|jpe?g|svg)$/i,
            use: [
            {
                loader: 'image-webpack-loader',
                options: {
                    mozjpeg: {
                        progressive: true,
                        quality: 65
                    },

                    optipng: {
                        enabled: false,
                    },
                    pngquant: {
                        quality: [0.65, 0.90],
                        speed: 4
                    },
                    gifsicle: {
                        interlaced: false,
                    },

                    webp: {
                        quality: 75
                    },
                }
            },
            {
                loader: 'file-loader',
                options:{
                    name: '[name].[ext]',
                    outputPath: 'images/',
                    publicPath: 'images/'
                }
            },
            'url-loader?limit=100000'
            ],
        },
        {
            test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
            use: [
            {
                loader: 'file-loader',
                options: {
                    name: '[name].[ext]',
                    outputPath: 'fonts/'
                }
            }
            ]
        },
        {
            test: /\.s[ac]ss$/i,
            use: [
            MiniCssExtractPlugin.loader,
            { loader: 'css-loader?url=false'},
            { loader: 'sass-loader', options: { sourceMap: true } }
            ],
        },
        ]
    },
    resolve: { extensions: ["*", ".js", ".jsx"] },
    output: {
        path: path.resolve(__dirname, "dist/"),
        publicPath: "",
        filename: "bundle.js"
    },
    devServer: {
        contentBase: path.join(__dirname, "dist/"),
        port: 3000,
        publicPath: "http://localhost:3000/dist/",
        hotOnly: true
    },
    plugins: [ new MiniCssExtractPlugin(),
    new CopyPlugin([{ from: 'src/images/', to: 'images/' }]),
    new CopyPlugin([{ from: 'src/fonts/', to: 'fonts/' }]),
    new ImageminPlugin({ test: /\.(jpe?g|png|gif|svg)$/i }),
    new HtmlWebpackPlugin({
        hash: true,
        template: './src/index.html',
            filename: './index.html' //relative to root of the application
        }),
    ]
};
10
votes

I was able to solve the problem myself. In case it could help others in the future, please find the solution below.


  1. First of all, if you are using both postcss-loader with the postcss-import plugin, AND css-loader, turn off / delete the postcss-import plugin. You do not need more than one tool that resolves @import rules. This is not really a problem if the order of loaders is correct, but you might as well remove it.
  2. In the sass-loader docs, you can read the following:

Since Sass/libsass does not provide url rewriting, all linked assets must be relative to the output.

  • If you're just generating CSS without passing it to the css-loader, it must be relative to your web root.

  • If you pass the generated CSS on to the css-loader, all urls must be relative to the entry-file (e.g. main.scss).

More likely you will be disrupted by this second issue. It is natural to expect relative references to be resolved against the .scss file in which they are specified (like in regular .css files). Thankfully there are two solutions to this problem:

  • Add the missing url rewriting using the resolve-url-loader. Place it before the sass-loader in the loader chain.

  • Library authors usually provide a variable to modify the asset path. bootstrap-sass for example has an $icon-font-path. Check out this working bootstrap example.

I decided to follow bullet two, and add in resolve-url-loader above sass-loader in the Webpack config. It now works as expected.

My final Webpack config (for now) looks like this:

    {
      test: /\.s?[ac]ss$/,
      exclude: /node_modules/,
      use: [
        isProduction
          ? MiniCssExtractPlugin.loader
          : {
              // creates style nodes from JS strings
              loader: 'style-loader',
              options: {
                sourceMap: true,
                // convertToAbsoluteUrls: true
              }
            },
        {
          // CSS to CommonJS (resolves CSS imports into exported CSS strings)
          loader: 'css-loader',
          options: {
            sourceMap: true,
            importLoaders: 2
            // url: false,
            // import: false
          }
        },
        {
          loader: 'postcss-loader',
          options: {
            config: {
              ctx: {
                cssnext: {},
                cssnano: {},
                autoprefixer: {}
              }
            },
            sourceMap: true
          }
        },
        {
          loader: 'resolve-url-loader',
          options: {
            attempts: 1,
            sourceMap: true
          }
        },
        {
          // compiles Sass to CSS
          loader: 'sass-loader',
          options: { sourceMap: true }
        }
      ]
    },

Side Notes

  1. I noticed that source map paths under "no domain" in Chrome's debugger are repeated. If anyone figures out why, please do share
  2. Remember to include the below side effects in package.json, so tree shaking, which happens in production mode, does not delete the extracted css

    "sideEffects": [ ".css", ".scss" ],

7
votes

You can turn off processing of url() rules, btw. I have no idea, why this is a default behavior.

{
  loader: 'css-loader',
  options: {
    ...
    url: false,
  }
},