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:
convertToAbsoluteUrls
flag in style-loaderMy 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
}
};
};
I was able to solve the problem myself. In case it could help others in the future, please find the solution below.
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.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
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" ],